packages feed

statsd-rupp-0.4.0.1: test/Spec.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-orphans #-}

import Control.Monad
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as C
import Data.Char
import Data.HashMap.Strict qualified as HashMap
import Data.List (sort)
import Data.Maybe
import Data.Vector qualified as V
import Network.Socket
import Network.Socket.ByteString
import System.Metrics.StatsD
import System.Metrics.StatsD.Internal
import Test.Hspec
import Test.Hspec.QuickCheck
import Test.QuickCheck
import UnliftIO
  ( MonadIO (liftIO),
    MonadUnliftIO,
    atomically,
    bracket,
    link,
    newTQueueIO,
    newTVarIO,
    readTQueue,
    withAsync,
    writeTQueue,
  )

main :: IO ()
main = hspec $ do
  describe "parser" $ do
    prop "formats and parses a report" $ \r ->
      (parseReport . formatReport) r `shouldBe` Just r
  describe "statistics" $ do
    prop "calculates timing lengths" $ \ps -> do
      let ts = makeTimingStats ps
      length ts.timings `shouldBe` length ps
    prop "calculates p75 and p95 timing lengths" $ \ps -> do
      let ts = makeTimingStats ps
      length (trimPercentile 95 ts).timings `shouldBe` length (percent 95 ps)
      length (trimPercentile 75 ts).timings `shouldBe` length (percent 75 ps)
    prop "computes median" $ \ps ->
      not (null ps) ==> do
        let ts = makeTimingStats ps
        median ts `shouldBe` med ps
    prop "computes p75 and p95 median" $ \ps ->
      2 <= length ps ==> do
        let ts = makeTimingStats ps
        median (trimPercentile 95 ts) `shouldBe` med (percent 95 ps)
        median (trimPercentile 75 ts) `shouldBe` med (percent 75 ps)
    prop "computes mean" $ \ps ->
      not (null ps) ==> do
        let ts = makeTimingStats ps
        mean ts `shouldBe` mea ps
    prop "computes p75 and p95 mean" $ \ps ->
      2 <= length ps ==> do
        let ts = makeTimingStats ps
        mean (trimPercentile 95 ts) `shouldBe` mea (percent 95 ps)
        mean (trimPercentile 75 ts) `shouldBe` mea (percent 75 ps)
    prop "computes stdev" $ \ps ->
      not (null ps) ==> do
        let ts = makeTimingStats ps
        stdev ts `shouldBe` std ps
    prop "computes sums" $ \ps ->
      not (null ps) ==> do
        let ts = makeTimingStats ps
        V.last ts.cumsums `shouldBe` sum ps
    prop "computes p75 and p95 sums" $ \ps ->
      2 <= length ps ==> do
        let ts = makeTimingStats ps
        V.last (trimPercentile 95 ts).cumsums `shouldBe` sum (percent 95 ps)
        V.last (trimPercentile 75 ts).cumsums `shouldBe` sum (percent 75 ps)
    prop "computes sum of squares" $ \ps ->
      not (null ps) ==> do
        let ts = makeTimingStats ps
        V.last ts.cumsquares `shouldBe` sumsq ps
    prop "computes p75 and p95 sum of squares" $ \ps ->
      2 <= length ps ==> do
        let ts = makeTimingStats ps
        V.last (trimPercentile 95 ts).cumsquares `shouldBe` sumsq (percent 95 ps)
        V.last (trimPercentile 75 ts).cumsquares `shouldBe` sumsq (percent 75 ps)
  around (withMockStats testConfig) $ do
    describe "counter" $ do
      it "creates two counters" $ \(stats, _) -> do
        _ <- newStatCounter stats "planets" 1
        _ <- newStatCounter stats "moons" 1
        return () :: Expectation
      it "reports on two counters" $ \(stats, report) -> do
        planets <- newStatCounter stats "planets" 1
        moons <- newStatCounter stats "moons" 1
        incrementCounter planets 10
        incrementCounter moons 20
        rs <- replicateM 2 report
        let rs' =
              [ Report "planets" (Counter 10) 1.0,
                Report "moons" (Counter 20) 1.0
              ]
        rs `shouldMatchList` rs'
      it "filters reported samples" $ \(stats, report) -> do
        c <- newStatCounter stats "moons" 4
        let counts = [11 .. 30]
        forM_ counts (incrementCounter c)
        rs <- replicateM 5 report
        let rs' =
              [ Report "moons" (Counter x) 0.25
                | (i, x) <- zip [(1 :: Int) ..] counts,
                  i `mod` 4 == 0
              ]
        rs `shouldMatchList` rs'
        r <- report
        r.key `shouldNotBe` "moons"
      it "reports stats" $ \(stats, report) -> do
        c <- newStatCounter stats "planets" 4
        replicateM_ 20 (incrementCounter c 10)
        replicateM_ 5 report
        do
          rs <- replicateM 2 report
          let rs' =
                [ Report "stats.counters.planets.count" (Counter 200) 1.0,
                  Report "stats.counters.planets.rate" (Counter 4000) 1.0
                ]
          rs `shouldMatchList` rs'
        do
          rs <- replicateM 2 report
          let rs' =
                [ Report "stats.counters.planets.count" (Counter 0) 1.0,
                  Report "stats.counters.planets.rate" (Counter 0) 1.0
                ]
          rs `shouldMatchList` rs'
    describe "timing" $ do
      it "reports events" $ \(stats, report) -> do
        let timings = [51 .. 55]
        t <- newStatTiming stats "trips" 1
        forM_ timings (addTiming t)
        rs <- replicateM (length timings) report
        let rs' = map (\n -> Report "trips" (Timing n) 1.0) timings
        rs `shouldMatchList` rs'
      it "reports empty stats" $ \(stats, report) -> do
        _ <- newStatTiming stats "holes" 1
        replicateM_ 2 $ do
          let rs' =
                [ Report "stats.timers.holes.count" (Counter 0) 1.0,
                  Report "stats.timers.holes.count_ps" (Counter 0) 1.0,
                  Report "stats.timers.holes.count_90" (Counter 0) 1.0,
                  Report "stats.timers.holes.count_95" (Counter 0) 1.0
                ]
          rs <- replicateM (length rs') report
          rs `shouldMatchList` rs'
      it "filters reported samples" $ \(stats, report) -> do
        let timings = [1001 .. 2000]
        t <- newStatTiming stats "cats" 100
        forM_ timings (addTiming t)
        rs <- replicateM 10 report
        let rs' =
              [ Report "cats" (Timing x) 0.01
                | (i, x) <- zip [(1 :: Int) ..] timings,
                  i `mod` 100 == 0
              ]
        rs `shouldMatchList` rs'
      it "reports stats" $ \(stats, report) -> do
        let timings = [5 .. 1500]
        t <- newStatTiming stats "kittens" 0
        forM_ timings (addTiming t)
        let l = length timings
            r k v = Report ("stats.timers.kittens." <> k) v 1.0
        do
          let rs' =
                [ r "count" (Counter l),
                  r "count_ps" (Counter (l * 20)),
                  r "std" (Timing 432),
                  r "mean" (Timing 752),
                  r "lower" (Timing 5),
                  r "upper" (Timing 1500),
                  r "sum" (Timing 1125740),
                  r "sum_squares" (Timing 1126125220),
                  r "median" (Timing 752),
                  r "count_95" (Counter 1421),
                  r "mean_95" (Timing 715),
                  r "lower_95" (Timing 5),
                  r "upper_95" (Timing 1425),
                  r "sum_95" (Timing 1016015),
                  r "sum_squares_95" (Timing 965562395),
                  r "median_95" (Timing 715),
                  r "count_90" (Counter 1346),
                  r "mean_90" (Timing 677),
                  r "lower_90" (Timing 5),
                  r "upper_90" (Timing 1350),
                  r "sum_90" (Timing 911915),
                  r "sum_squares_90" (Timing 821036445),
                  r "median_90" (Timing 677)
                ]
          rs <- replicateM (length rs') report
          rs `shouldMatchList` rs'
        do
          let rs' =
                concatMap
                  (replicate 2)
                  [ r "count" (Counter 0),
                    r "count_ps" (Counter 0),
                    r "count_90" (Counter 0),
                    r "count_95" (Counter 0)
                  ]
          rs <- replicateM (length rs') report
          rs `shouldMatchList` rs'
    describe "gauge" $ do
      it "reports set events" $ \(stats, report) -> do
        let gauges = [51 .. 55]
        g <- newStatGauge stats "speed" 50
        forM_ gauges (setGauge g)
        rs <- replicateM (length gauges) report
        let rs' = map (\n -> Report "speed" (Gauge n False) 1.0) gauges
        rs `shouldMatchList` rs'
      it "reports stats" $ \(stats, report) -> do
        let gauges = [51 .. 55]
        g <- newStatGauge stats "radius" 50
        r <- report
        r `shouldBe` Report "stats.gauges.radius" (Gauge 50 False) 1.0
        forM_ gauges (setGauge g)
        replicateM_ (length gauges) report
        rs <- replicateM 2 report
        let rs' =
              replicate 2 $
                Report "stats.gauges.radius" (Gauge 55 False) 1.0
        rs `shouldMatchList` rs'
      it "increments and decrements value" $ \(stats, report) -> do
        g <- newStatGauge stats "breadth" 50
        let sk = "breadth"
            tk = "stats.gauges.breadth"
            r k v t = Report k (Gauge v t) 1.0
        incrementGauge g 5
        report `shouldReturn` r sk 5 True
        report `shouldReturn` r tk 55 False
        incrementGauge g (-10)
        report `shouldReturn` r sk (-10) True
        report `shouldReturn` r tk 45 False
        decrementGauge g 50
        report `shouldReturn` r sk (-50) True
        report `shouldReturn` r tk 0 False
        decrementGauge g (-25)
        report `shouldReturn` r sk 25 True
        report `shouldReturn` r tk 25 False
        incrementGauge g maxBound
        report `shouldReturn` r sk maxBound True
        report `shouldReturn` r tk maxBound False
        decrementGauge g minBound
        report `shouldReturn` r sk minBound True
        report `shouldReturn` r tk 0 False
        incrementGauge g maxBound
        report `shouldReturn` r sk maxBound True
        report `shouldReturn` r tk maxBound False
        decrementGauge g 5
        report `shouldReturn` r sk (-5) True
        report `shouldReturn` r tk (maxBound - 5) False
    describe "set" $ do
      it "reports events" $ \(stats, report) -> do
        let set = ["one", "two", "three", "three"]
        s <- newStatSet stats "potatoes"
        forM_ set (newSetElement s)
        rs <- replicateM (length set) report
        let rs' = map (\x -> Report "potatoes" (Set (C.pack x)) 1.0) set
        rs `shouldMatchList` rs'
      it "reports stats" $ \(stats, report) -> do
        let set = ["two", "three", "one", "two", "three", "three"]
        s <- newStatSet stats "bananas"
        forM_ set (newSetElement s)
        let rs' =
              [ Report "stats.sets.bananas.count" (Counter 3) 1.0,
                Report "stats.sets.bananas.count" (Counter 0) 1.0
              ]
        rs <- replicateM (length set + length rs') report
        rs `shouldEndWith` rs'

withMockStats ::
  (MonadUnliftIO m) =>
  StatConfig ->
  ((Stats, m Report) -> m a) ->
  m a
withMockStats cfg go =
  withMockSockets $ \(s1, s2) -> do
    q <- newTQueueIO
    withAsync (fwd s2 q) $ \a1 -> do
      link a1
      m <- newTVarIO HashMap.empty
      let s = newStats cfg {appendNewline = True} m s1
      withAsync (statsLoop s) $ \a2 -> do
        link a2
        go (s, atomically (readTQueue q))
  where
    fwd s2 q = forever $ do
      bs <- liftIO $ C.lines <$> recv s2 (2 ^ (20 :: Int))
      let rs = map (fromJust . parseReport) bs
      mapM_ (atomically . writeTQueue q) rs

withMockSockets :: (MonadUnliftIO m) => ((Socket, Socket) -> m a) -> m a
withMockSockets =
  bracket
    (liftIO (socketPair AF_UNIX Stream 0))
    (\(s1, s2) -> liftIO (close s1 >> close s2))

testConfig :: StatConfig
testConfig = defStatConfig {flushInterval = 50}

med :: [Int] -> Int
med ls
  | odd (length ls) =
      sort ls !! (length ls `div` 2)
  | otherwise =
      let ix = length ls `div` 2
          x = sort ls !! ix
          y = sort ls !! (ix - 1)
       in (x + y) `div` 2

mea :: [Int] -> Int
mea ls = sum ls `div` length ls

sumsq :: [Int] -> Int
sumsq = sum . map (\x -> x * x)

percent :: Int -> [Int] -> [Int]
percent pc ls = take (length ls * pc `div` 100) (sort ls)

std :: [Int] -> Int
std ls =
  let l = fromIntegral (length ls)
      ds = map (subtract (mea ls)) ls
      s = fromIntegral $ sumsq ds :: Double
      v = s / l
   in round (sqrt v)

genValidString :: Gen ByteString
genValidString =
  C.pack <$> listOf1 (arbitraryASCIIChar `suchThat` isValidChar)

isValidChar :: Char -> Bool
isValidChar c =
  isAscii c && (isAlpha c || isNumber c || isWhitelist c)
  where
    isWhitelist = flip elem (".-_" :: [Char])

genRate :: Gen Double
genRate = arbitrary `suchThat` \n -> n > 0.0 && n <= 1.0

genNatural :: Gen Int
genNatural = arbitrary `suchThat` (0 <=)

instance Arbitrary Value where
  arbitrary =
    oneof [counter, gauge, timing, set]
    where
      counter = Counter <$> genNatural
      timing = Timing <$> genNatural
      set = Set <$> genValidString
      gauge = do
        t <- arbitrary
        v <- if t then arbitrary else genNatural
        return $ Gauge v t

instance Arbitrary Report where
  arbitrary = do
    key <- genValidString
    value <- arbitrary
    rate <- case value of
      Counter {} -> genRate
      Gauge {} -> pure 1.0
      Timing {} -> genRate
      Set {} -> pure 1.0
    return $ Report key value rate