otel-effectful-1.0.0: test/Effectful/OpenTelemetry/MetricsSpec.hs
{-# OPTIONS_GHC -Wno-type-defaults #-}
module Effectful.OpenTelemetry.MetricsSpec (spec) where
import Arbitrary
import Control.Monad (forM_)
import Data.Either (isLeft)
import Data.Foldable (for_)
import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)
import Data.List qualified as List
import Data.List.Extra (nubOrd)
import Data.Maybe (isJust, mapMaybe)
import Data.Text (Text)
import Effectful
import Effectful.Concurrent (runConcurrent, threadDelay)
import Effectful.Concurrent.Async (replicateConcurrently_)
import Effectful.Exception (SomeException, throwIO, try)
import Effectful.HUnit (HUnit, assertFailure)
import Effectful.Hspec
import Effectful.OpenTelemetry.Exporter qualified as Exporter
import Effectful.OpenTelemetry.Metrics
import Effectful.OpenTelemetry.Metrics.Counter qualified as Counter
import Effectful.OpenTelemetry.Metrics.Gauge qualified as Gauge
import Effectful.OpenTelemetry.Metrics.Histogram qualified as Histogram
import Effectful.OpenTelemetry.Metrics.Metadata
import Effectful.OpenTelemetry.Metrics.ObservableCounter qualified as ObservableCounter
import Effectful.OpenTelemetry.Metrics.ObservableGauge qualified as ObservableGauge
import Effectful.OpenTelemetry.Metrics.ObservableUpDownCounter qualified as ObservableUpDownCounter
import Effectful.OpenTelemetry.Metrics.RTS qualified as Metrics.RTS
import Effectful.OpenTelemetry.Metrics.Sum qualified as Sum
import Effectful.OpenTelemetry.Metrics.UpDownCounter qualified as UpDownCounter
import Effectful.OpenTelemetry.Timestamp (Timestamp (..))
import Effectful.QuickCheck (NonNegative (..), Small (..))
import Util
import Prelude
getLastMeasurement :: (HUnit :> es) => [Measurement] -> Eff es Measurement
getLastMeasurement [] = assertFailure "Expected at least 1 measurement"
getLastMeasurement ms = pure $ last ms
named :: Text -> [Measurement] -> Maybe Measurement
named n = List.find ((n ==) . (.name))
spec :: (IOE :> es, HUnit :> es, Hspec :> es) => Eff es ()
spec = runConcurrent . parallel $ do
describe "Counter" do
it "has initial value zero" do
m <-
only . snd =<< runInMemoryMetrics do
_ <- Counter.new "requests" mempty
pure ()
m.name `shouldBe` "requests"
numberValue m `shouldBe` Just 0
prop "increments monotonically" \(NonNegative (Small a)) (NonNegative (Small b)) -> do
m <-
only . snd =<< runInMemoryMetrics do
c <- Counter.new "ops" mempty
Counter.add c a
Counter.add c b
numberValue m `shouldBe` Just (a + b)
Sum.Payload{isMonotonic} <- maybe (assertFailure "not a Sum") pure $ toMetric m
isMonotonic `shouldBe` True
prop "carries its metadata" \metadata -> do
m <-
only . snd =<< runInMemoryMetrics do
c <- Counter.new "documented" metadata
Counter.add c 1
m.description `shouldBe` metadata.description
m.unit `shouldBe` metadata.unit
dp <- maybe (assertFailure "no number data point") pure $ numberDataPoint m
dp.attributes `shouldBe` metadata.attributes
describe "UpDownCounter" do
it "has initial value zero" do
m <-
only . snd =<< runInMemoryMetrics do
_ <- UpDownCounter.new "requests" mempty
pure ()
m.name `shouldBe` "requests"
numberValue m `shouldBe` Just 0
prop "increments non-monotonically" \(Small a) (Small b) -> do
m <-
only . snd =<< runInMemoryMetrics do
c <- UpDownCounter.new "ops" mempty
UpDownCounter.add c a
UpDownCounter.add c b
numberValue m `shouldBe` Just (a + b)
Sum.Payload{isMonotonic} <- maybe (assertFailure "not a Sum") pure $ toMetric m
isMonotonic `shouldBe` False
prop "carries its metadata" \metadata -> do
m <-
only . snd =<< runInMemoryMetrics do
c <- UpDownCounter.new "documented" metadata
UpDownCounter.add c 1
m.description `shouldBe` metadata.description
m.unit `shouldBe` metadata.unit
dp <- maybe (assertFailure "no number data point") pure $ numberDataPoint m
dp.attributes `shouldBe` metadata.attributes
describe "data point attributes" do
prop "carries its attributes" \attrs -> do
m <-
only . snd =<< runInMemoryMetrics do
c <-
Counter.new
"labelled"
Metadata{attributes = attrs, description = Nothing, unit = Nothing}
Counter.add c 1
dp <- maybe (assertFailure "no number data point") pure $ numberDataPoint m
dp.attributes `shouldBe` attrs
describe "Gauge" do
it "never set, never exported" do
(_, measurements) <- runInMemoryMetrics do
_ <- Gauge.new "temperature" mempty
pure ()
measurements `shouldSatisfy` null
prop "last value wins" \(NonNegative a) (NonNegative b) (NonNegative c) -> do
m <-
getLastMeasurement . snd =<< runInMemoryMetrics do
g <- Gauge.new "memory" mempty
Gauge.set g a
Gauge.set g b
Gauge.set g c
numberValue m `shouldBe` Just c
describe "Multiple instruments" do
prop "are independent" \(NonNegative (Small a)) (NonNegative v) -> do
(_, measurements) <- runInMemoryMetrics do
c <- UpDownCounter.new "req_count" mempty
g <- Gauge.new "req_latency" mempty
UpDownCounter.add c a
Gauge.set g v
length measurements `shouldBe` 2
(numberValue =<< named "req_count" measurements) `shouldBe` Just a
(numberValue =<< named "req_latency" measurements) `shouldBe` Just v
describe "Concurrency" do
prop "up-down counter increments are atomic" \ConcurrentObservation{repeats} (NonNegative (Small value)) -> do
m <-
only . snd =<< (runConcurrent . runInMemoryMetrics) do
c <- UpDownCounter.new "concurrent" mempty
replicateConcurrently_ repeats $ UpDownCounter.add c value
numberValue m `shouldBe` Just (fromIntegral repeats * value)
it "gauge sets converge" do
m <-
only . snd =<< (runConcurrent . runInMemoryMetrics) do
g <- Gauge.new "contended" mempty
replicateConcurrently_ 50 $ Gauge.set g 42
numberValue m `shouldBe` Just 42
it "instrument creation is safe" do
m <-
only . snd =<< (runConcurrent . runInMemoryMetrics) do
replicateConcurrently_ 20 do
c <- UpDownCounter.new "shared" mempty
UpDownCounter.add c 1
pure ()
m.name `shouldBe` "shared"
describe "Histogram" do
it "starts empty" do
m <-
only . snd =<< runInMemoryMetrics do
_ <- Histogram.new "latency_ms" mempty
pure ()
m.name `shouldBe` "latency_ms"
dp <- maybe (assertFailure "no histogram data point") pure $ histogramDataPoint m
dp.count `shouldBe` 0
dp.sum `shouldBe` Nothing
prop "bounds and stats match" \(HistogramBounds bounds) (HistogramObservations samples) -> do
m <-
only . snd =<< runInMemoryMetrics do
h <- Histogram.newWithBounds "weights" bounds mempty
mapM_ (Histogram.record h) samples
dp <- maybe (assertFailure "no histogram data point") pure $ histogramDataPoint m
dp.explicitBounds `shouldBe` List.sort bounds
dp.count `shouldBe` fromIntegral (length samples)
case dp.sum of
Just s -> abs (s - Prelude.sum samples) `shouldSatisfy` (< 1e-9)
Nothing -> expectationFailure "no sum reported"
dp.minValue `shouldBe` Just (minimum samples)
dp.maxValue `shouldBe` Just (maximum samples)
prop "carries its attributes" \attributes -> do
m <-
only . snd =<< runInMemoryMetrics do
h <- Histogram.new "attributed" Metadata{attributes, description = Nothing, unit = Nothing}
mapM_ (Histogram.record h) [1, 2, 3]
dp <- maybe (assertFailure "no histogram data point") pure $ histogramDataPoint m
dp.attributes `shouldBe` attributes
prop "gauges and histograms carry metadata too" \metadataG metadataH (NonNegative v) -> do
(_, measurements) <- runInMemoryMetrics do
g <- Gauge.new "temperature" metadataG
Gauge.set g v
_ <- Histogram.new "latency" metadataH
pure ()
length measurements `shouldBe` 2
tempMeasurement <-
maybe (assertFailure "temperature not found") pure $ named "temperature" measurements
histMeasurement <- maybe (assertFailure "latency not found") pure $ named "latency" measurements
tempMeasurement.description `shouldBe` metadataG.description
tempMeasurement.unit `shouldBe` metadataG.unit
histMeasurement.description `shouldBe` metadataH.description
histMeasurement.unit `shouldBe` metadataH.unit
it "start precedes observation" do
(_, measurements) <- runInMemoryMetrics do
c <- Counter.new "started_total" mempty
Counter.add c 1
g <- Gauge.new "started_gauge" mempty
Gauge.set g 1
h <- Histogram.new "started_hist" mempty
Histogram.record h 1
let numberWindow m = (\dp -> (dp.startTime, dp.time)) <$> numberDataPoint m
histogramWindow m = (\dp -> (dp.startTime, dp.time)) <$> histogramDataPoint m
windows :: [(Timestamp, Timestamp)]
windows = mapMaybe numberWindow measurements <> mapMaybe histogramWindow measurements
length windows `shouldBe` length measurements
windows `shouldSatisfy` all \(start, time) -> start.nanos > 0 && start <= time
prop "bucket placement" \BucketedObservations{..} -> do
m <-
only . snd =<< runInMemoryMetrics do
h <- Histogram.newWithBounds "buckets" bounds mempty
mapM_ (Histogram.record h) $ mconcat observations
dp <- maybe (assertFailure "no histogram data point") pure $ histogramDataPoint m
dp.bucketCounts `shouldBe` (fromIntegral . length <$> observations)
prop "concurrent observations are atomic" \ConcurrentObservation{..} -> do
m <-
only . snd =<< (runConcurrent . runInMemoryMetrics) do
h <- Histogram.new "concurrent_obs" mempty
replicateConcurrently_ repeats $ Histogram.record h value
dp <- maybe (assertFailure "no histogram data point") pure $ histogramDataPoint m
dp.count `shouldBe` fromIntegral repeats
describe "asynchronous instruments" do
prop "reports under its own name" \(NonNegative v) -> do
m <-
only . snd =<< runInMemoryMetrics do
_ <- ObservableGauge.new "obs.ratio" mempty . pure $ Just v
pure ()
numberValue m `shouldBe` Just v
prop "each kind reports correctly" \(NonNegative (Small a)) (NonNegative (Small b)) (NonNegative c) -> do
(_, measurements) <- runInMemoryMetrics do
_ <- ObservableCounter.new "obs.total" mempty $ pure a
_ <- ObservableUpDownCounter.new "obs.size" mempty $ pure b
_ <- ObservableGauge.new "obs.ratio" mempty . pure $ Just c
pure ()
(numberValue =<< named "obs.total" measurements) `shouldBe` Just a
(numberValue =<< named "obs.size" measurements) `shouldBe` Just b
(numberValue =<< named "obs.ratio" measurements) `shouldBe` Just c
Sum.Payload{isMonotonic = totalMonotonic} <-
maybe (assertFailure "obs.total not found") pure $ toMetric =<< named "obs.total" measurements
Sum.Payload{isMonotonic = sizeMonotonic} <-
maybe (assertFailure "obs.size not found") pure $ toMetric =<< named "obs.size" measurements
totalMonotonic `shouldBe` True
sizeMonotonic `shouldBe` False
it "observes lazily" do
ref <- liftIO $ newIORef 1
m <-
only . snd =<< runInMemoryMetrics do
_ <- ObservableGauge.new "obs.late" mempty $ Just <$> readIORef ref
liftIO $ writeIORef ref 42
numberValue m `shouldBe` Just 42
it "one callback per export" do
ref <- liftIO $ newIORef (0 :: Int)
_ <- runInMemoryMetrics do
_ <- ObservableGauge.new "obs.counted" mempty do
calls <- atomicModifyIORef' ref \n -> (n + 1, n + 1)
pure . Just $ fromIntegral calls
pure ()
calls <- liftIO $ readIORef ref
calls `shouldBe` 1
it "nothing observed, nothing reported" do
(_, measurements) <- runInMemoryMetrics do
_ <- ObservableGauge.new "obs.empty" mempty $ pure Nothing
pure ()
measurements `shouldSatisfy` null
describe "runMetricsWith" do
it "samples once, even if the action raises" do
sunk <- liftIO $ newIORef []
(result :: Either SomeException ()) <-
try @SomeException . runMetricsWith Nothing (Exporter.fromIO \m -> modifyIORef' sunk (<> [m])) $ do
c <- Counter.new "with.ops" mempty
Counter.add c 5
liftIO (length <$> readIORef sunk) `shouldReturn` 0
throwIO $ userError "boom"
result `shouldSatisfy` isLeft
m <- only =<< liftIO (readIORef sunk)
numberValue m `shouldBe` Just 5
it "samples repeatedly when given an interval" do
sunk <- liftIO $ newIORef []
runMetricsWith (Just 20) (Exporter.fromIO \m -> modifyIORef' sunk (<> [m])) do
c <- Counter.new "with.periodic" mempty
forM_ [1 .. 5 :: Int] . const $ Counter.add c 1 >> threadDelay 50_000
measurements <- liftIO (readIORef sunk)
mapMaybe numberValue measurements `shouldBe` [1, 2, 3, 4, 5]
describe "RTS metrics" do
it "reports readings" do
(_, measurements) <- runInMemoryMetrics Metrics.RTS.register
measurements `shouldSatisfy` any (("rts.allocated_bytes" ==) . (.name))
measurements `shouldSatisfy` any (("rts.gc.allocated_bytes" ==) . (.name))
it "observed at the same time" do
(_, measurements) <- runInMemoryMetrics Metrics.RTS.register
let times = mapMaybe (fmap (.time) . numberDataPoint) measurements
times `shouldSatisfy` (1 ==) . length . nubOrd
it "readings have metadata" do
(_, measurements) <- runInMemoryMetrics Metrics.RTS.register
for_ measurements \m -> do
m.name `shouldNotBe` ""
m.description `shouldSatisfy` isJust
m.unit `shouldSatisfy` isJust