packages feed

statsd-rupp-0.4.0.2: src/System/Metrics/StatsD/Internal.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoFieldSelectors #-}

module System.Metrics.StatsD.Internal
  ( Stats (..),
    newStats,
    StatParams,
    newParams,
    StatConfig (..),
    MetricData (..),
    Store (..),
    Metrics,
    newMetrics,
    Value (..),
    Sample (..),
    Report (..),
    StatCounter (..),
    StatGauge (..),
    StatTiming (..),
    StatSet (..),
    addMetric,
    newMetric,
    validateKey,
    addReading,
    newReading,
    processSample,
    statsLoop,
    statsFlush,
    flushStats,
    catKey,
    statReports,
    TimingStats (..),
    makeTimingStats,
    timingReports,
    trimPercentile,
    percentileSuffix,
    timingStats,
    cumulativeSums,
    cumulativeSquares,
    stdev,
    mean,
    median,
    flush,
    toReport,
    formatReport,
    submit,
    connectStatsD,
    parseReport,
    parseRead,
    parseInt,
  )
where

import Control.Monad (MonadPlus (..), forM_, forever, guard, unless, void, when)
import Data.Bool (bool)
import Data.ByteString (ByteString)
import Data.ByteString qualified as B
import Data.ByteString.Builder (byteString, char8, intDec, string8, toLazyByteString)
import Data.ByteString.Char8 qualified as C
import Data.ByteString.Lazy qualified as L
import Data.Char (isAlphaNum, isAscii)
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HashMap
import Data.HashSet (HashSet)
import Data.HashSet qualified as HashSet
import Data.List (nub, sort)
import Data.Vector (Vector, (!))
import Data.Vector qualified as V
import Network.Socket (Socket)
import Network.Socket qualified as Net
import Network.Socket.ByteString qualified as Net
import Text.Printf (printf)
import Text.Read (readMaybe)
import UnliftIO (MonadIO, handleIO, liftIO, throwIO)
import UnliftIO.Concurrent (threadDelay)
import UnliftIO.STM
  ( STM,
    TVar,
    atomically,
    modifyTVar,
    newTVarIO,
    readTVar,
    stateTVar,
  )

data Stats = Stats
  { metrics :: !(TVar Metrics),
    socket :: !Socket,
    params :: !StatParams
  }

data StatParams = StatParams
  { pfx :: !ByteString,
    pfxCounter :: !ByteString,
    pfxTimer :: !ByteString,
    pfxGauge :: !ByteString,
    pfxSet :: !ByteString,
    flush :: !Int,
    stats :: !Bool,
    samples :: !Bool,
    percentiles :: ![Int],
    newline :: !Bool
  }

data StatConfig = StatConfig
  { reportStats :: !Bool,
    reportSamples :: !Bool,
    namespace :: !String,
    prefixStats :: !String,
    prefixCounter :: !String,
    prefixTimer :: !String,
    prefixGauge :: !String,
    prefixSet :: !String,
    statsdServer :: !String,
    statsdPort :: !Int,
    flushInterval :: !Int,
    timingPercentiles :: ![Int],
    appendNewline :: !Bool
  }
  deriving (Show, Read, Eq, Ord)

data MetricData
  = CounterData !Int
  | GaugeData !Int
  | TimingData ![Int]
  | SetData !(HashSet ByteString)

data Store = Store
  { index :: !Int,
    dat :: !(Maybe MetricData)
  }

type Metrics = HashMap ByteString Store

data Value
  = Counter !Int
  | Gauge !Int !Bool
  | Timing !Int
  | Set !ByteString
  deriving (Eq, Ord, Show, Read)

data Report = Report
  { key :: !ByteString,
    value :: !Value,
    rate :: !Double
  }
  deriving (Eq, Ord, Show, Read)

data Sample = Sample
  { key :: !ByteString,
    value :: !Value,
    sampling :: !Int,
    index :: !Int
  }
  deriving (Eq, Ord, Show, Read)

data StatCounter = StatCounter
  { stats :: !Stats,
    key :: !ByteString,
    sampling :: !Int
  }

data StatGauge = StatGauge
  { stats :: !Stats,
    key :: !ByteString
  }

data StatTiming = StatTiming
  { stats :: !Stats,
    key :: !ByteString,
    sampling :: !Int
  }

data StatSet = StatSet
  { stats :: !Stats,
    key :: !ByteString
  }

addMetric :: StatParams -> ByteString -> MetricData -> Metrics -> Metrics
addMetric params key dat =
  HashMap.insert key $
    Store 0 $
      if params.stats then Just dat else Nothing

newMetric :: (MonadIO m) => Stats -> ByteString -> MetricData -> m ()
newMetric stats key store = do
  unless (validateKey key) $ do
    throwIO $ userError "Metric key is invalid"
  e <- atomically $ do
    exists <- HashMap.member key <$> readTVar stats.metrics
    if exists
      then return True
      else do
        modifyTVar
          stats.metrics
          (addMetric stats.params key store)
        return False
  when e $ throwIO $ userError $ "StatsD key exists: " <> C.unpack key

validateKey :: ByteString -> Bool
validateKey t = not (C.null t) && C.all valid t
  where
    valid c = elem c ("._-" :: [Char]) || isAscii c && isAlphaNum c

validateValue :: Value -> Bool
validateValue (Counter c) = c > 0
validateValue (Gauge g False) = g > 0
validateValue (Gauge _ True) = True
validateValue (Timing t) = t > 0
validateValue (Set e) = validateKey e

addReading :: Value -> ByteString -> Metrics -> Metrics
addReading reading = HashMap.adjust adjust
  where
    adjust m = m {index = m.index + 1, dat = change <$> m.dat}
    change store = case (reading, store) of
      (Counter c, CounterData s) ->
        CounterData (s + c)
      (Gauge i False, GaugeData _) ->
        GaugeData i
      (Gauge i True, GaugeData g)
        | i > maxBound - g -> GaugeData maxBound
        | otherwise -> GaugeData (max 0 (g + i))
      (Timing t, TimingData s) ->
        TimingData (t : s)
      (Set e, SetData s) ->
        SetData (HashSet.insert e s)
      _ -> error "Stats reading mismatch"

newReading :: Stats -> ByteString -> Value -> STM Int
newReading stats key reading = do
  modifyTVar stats.metrics (addReading reading key)
  maybe 0 (.index) . HashMap.lookup key
    <$> readTVar stats.metrics

processSample ::
  (MonadIO m) => Stats -> Int -> ByteString -> Value -> m ()
processSample stats sampling key val = do
  when (0 > sampling) $
    throwIO $
      userError "StatsD sampling rate must not be negative"
  unless (validateValue val) $
    throwIO $
      userError "StatsD value is not valid"
  idx <- atomically $ newReading stats key val
  when stats.params.samples $
    submit stats $
      Sample key val sampling idx

newMetrics :: (MonadIO m) => m (TVar Metrics)
newMetrics = newTVarIO HashMap.empty

newParams :: StatConfig -> StatParams
newParams cfg
  | v =
      StatParams
        { pfx = pfx,
          pfxCounter = s <> bc <> ".",
          pfxGauge = s <> bg <> ".",
          pfxTimer = s <> bt <> ".",
          pfxSet = s <> be <> ".",
          newline = cfg.appendNewline,
          stats = cfg.reportStats,
          samples = cfg.reportSamples,
          percentiles = 100 : nub cfg.timingPercentiles,
          flush = cfg.flushInterval
        }
  | otherwise = error "StatsD config invalid"
  where
    bn = C.pack cfg.namespace
    bs = C.pack cfg.prefixStats
    bg = C.pack cfg.prefixGauge
    bc = C.pack cfg.prefixCounter
    bt = C.pack cfg.prefixTimer
    be = C.pack cfg.prefixSet
    v =
      all validateKey [bs, bg, bc, bt, be]
        && bool (validateKey bn) True (null cfg.namespace)
        && cfg.flushInterval > 0
        && all (\pc -> pc > 0 && 100 > pc) cfg.timingPercentiles
    pfx =
      if null cfg.namespace
        then ""
        else bn <> "."
    s = pfx <> bs <> "."

newStats :: StatConfig -> TVar Metrics -> Socket -> Stats
newStats cfg metrics socket =
  Stats
    { metrics = metrics,
      socket = socket,
      params = newParams cfg
    }

statsLoop :: (MonadIO m) => Stats -> m ()
statsLoop stats = forever $ do
  threadDelay (stats.params.flush * 1000)
  statsFlush stats

statsFlush :: (MonadIO m) => Stats -> m ()
statsFlush stats = do
  mapM_ (send stats)
    =<< atomically
      (stateTVar stats.metrics (flushStats stats.params))

flushStats :: StatParams -> Metrics -> ([Report], Metrics)
flushStats params metrics =
  let f xs key m = maybe xs ((<> xs) . statReports params key) m.dat
      rs = HashMap.foldlWithKey' f [] metrics
      g m = m {dat = flush <$> m.dat}
      ms = HashMap.map g metrics
   in (rs, ms)

catKey :: [ByteString] -> ByteString
catKey = C.intercalate "." . filter (not . C.null)

statReports :: StatParams -> ByteString -> MetricData -> [Report]
statReports params key dat = case dat of
  CounterData c ->
    [ Report
        { key = params.pfxCounter <> key <> ".count",
          value = Counter c,
          rate = 1.0
        },
      Report
        { key = params.pfxCounter <> key <> ".rate",
          value = Counter (computeRate params c),
          rate = 1.0
        }
    ]
  GaugeData s ->
    [ Report
        { key = params.pfxGauge <> key,
          value = Gauge s False,
          rate = 1.0
        }
    ]
  SetData s ->
    [ Report
        { key = params.pfxSet <> key <> ".count",
          value = Counter (HashSet.size s),
          rate = 1.0
        }
    ]
  TimingData s -> timingReports params key s

data TimingStats = TimingStats
  { timings :: !(Vector Int),
    cumsums :: !(Vector Int),
    cumsquares :: !(Vector Int)
  }
  deriving (Eq, Ord, Show, Read)

makeTimingStats :: [Int] -> TimingStats
makeTimingStats timings =
  TimingStats
    { timings = V.fromList sorted,
      cumsums = V.fromList (cumulativeSums sorted),
      cumsquares = V.fromList (cumulativeSquares sorted)
    }
  where
    sorted = sort timings

timingReports :: StatParams -> ByteString -> [Int] -> [Report]
timingReports params key timings =
  concatMap (timingStats params key tstats) params.percentiles
  where
    tstats = makeTimingStats timings

trimPercentile :: Int -> TimingStats -> TimingStats
trimPercentile pc ts =
  ts
    { timings = f ts.timings,
      cumsums = f ts.cumsums,
      cumsquares = f ts.cumsquares
    }
  where
    f ls = V.take (length ls * pc `div` 100) ls

percentileSuffix :: Int -> ByteString
percentileSuffix pc
  | pc == 100 = ""
  | otherwise = C.pack $ printf "_%d" pc

computeRate :: StatParams -> Int -> Int
computeRate params i = i * 1000 `div` params.flush

mean :: TimingStats -> Int
mean ts = V.last ts.cumsums `div` length ts.timings

timingStats :: StatParams -> ByteString -> TimingStats -> Int -> [Report]
timingStats params key tstats pc =
  concat
    [ [mkr "count" (Counter (length ts.timings))],
      [mkr "count_ps" (Counter rate) | pc == 100],
      [mkr "std" (Timing (stdev ts)) | pc == 100, not empty],
      [mkr "mean" (Timing (mean ts)) | not empty],
      [mkr "upper" (Timing (V.last ts.timings)) | not empty],
      [mkr "lower" (Timing (V.head ts.timings)) | not empty],
      [mkr "sum" (Timing (V.last ts.cumsums)) | not empty],
      [mkr "sum_squares" (Timing (V.last ts.cumsquares)) | not empty],
      [mkr "median" (Timing (median ts)) | not empty]
    ]
  where
    ts = trimPercentile pc tstats
    empty = null ts.timings
    rate = computeRate params (length ts.timings)
    sfx = percentileSuffix pc
    pfx = params.pfxTimer <> key <> "."
    mkr s v = Report {key = pfx <> s <> sfx, value = v, rate = 1.0}

cumulativeSums :: (Num a) => [a] -> [a]
cumulativeSums = scanl1 (+)

cumulativeSquares :: (Num a) => [a] -> [a]
cumulativeSquares = scanl1 (+) . map (\x -> x * x)

stdev :: TimingStats -> Int
stdev ts =
  round $ sqrt var
  where
    len = length ts.timings
    var = fromIntegral ds / fromIntegral len :: Double
    ds = sum $ map ((^ (2 :: Int)) . subtract (mean ts)) (V.toList ts.timings)

median :: TimingStats -> Int
median ts
  | null ts.timings = 0
  | even (length ts.timings) =
      let lower = ts.timings ! middle
          upper = ts.timings ! subtract 1 middle
       in (lower + upper) `div` 2
  | otherwise =
      ts.timings ! middle
  where
    middle = length ts.timings `div` 2

flush :: MetricData -> MetricData
flush (CounterData _) = CounterData 0
flush (GaugeData g) = GaugeData g
flush (TimingData _) = TimingData []
flush (SetData _) = SetData HashSet.empty

toReport :: Sample -> Maybe Report
toReport sample
  | sample.sampling > 0 && sample.index `mod` sample.sampling == 0 =
      Just
        Report
          { key = sample.key,
            value = sample.value,
            rate = 1.0 / fromIntegral sample.sampling
          }
  | otherwise = Nothing

formatReport :: Report -> ByteString
formatReport report = L.toStrict $ toLazyByteString builder
  where
    builder = byteString report.key <> char8 ':' <> val
    rate
      | report.rate < 1.0 =
          string8 $ printf "|@%f" report.rate
      | otherwise = mempty
    val =
      case report.value of
        Counter i ->
          intDec i <> "|c" <> rate
        Gauge g False ->
          intDec g <> "|g"
        Gauge g True ->
          let sign = if 0 <= g then char8 '+' else mempty
           in sign <> intDec g <> "|g"
        Timing t ->
          intDec t <> "|ms" <> rate
        Set e ->
          byteString e <> "|s"

submit :: (MonadIO m) => Stats -> Sample -> m ()
submit stats sample =
  forM_ (toReport sample) (send stats)

send :: (MonadIO m) => Stats -> Report -> m ()
send stats report =
  liftIO $
    handleIO (const (return ())) $
      void $
        Net.send
          stats.socket
          (formatReport report <> bool "" "\n" stats.params.newline)

connectStatsD :: (MonadIO m) => String -> Int -> m Socket
connectStatsD host port = liftIO $ do
  as <-
    Net.getAddrInfo
      Nothing
      (Just host)
      (Just $ show port)
  a <- case as of
    a : _ -> return a
    [] -> error $ "Cannot resolve: " <> host <> ":" <> show port
  sock <- Net.socket (Net.addrFamily a) Net.Datagram Net.defaultProtocol
  Net.connect sock (Net.addrAddress a)
  return sock

parseReport :: (MonadPlus m) => ByteString -> m Report
parseReport bs =
  case C.split '|' bs of
    [kv, t] -> do
      (k, v) <- parseKeyValue kv t
      return $ Report k v 1.0
    [kv, t, r] -> do
      (k, v) <- parseKeyValue kv t
      x <- parseRate r
      return $ Report k v x
    _ -> mzero
  where
    parseKeyValue kv t = do
      case C.split ':' kv of
        [key, v] -> do
          guard (validateKey key)
          value <- parseValue v t
          return (key, value)
        _ -> mzero
    parseValue v t =
      case C.unpack t of
        "c" -> Counter <$> parseNatural v
        "g" ->
          case C.uncons v of
            Just ('+', _) -> Gauge <$> parseInt v <*> pure True
            Just ('-', _) -> Gauge <$> parseInt v <*> pure True
            _ -> Gauge <$> parseNatural v <*> pure False
        "s" -> do
          guard (validateKey v)
          return (Set v)
        "ms" -> Timing <$> parseNatural v
        _ -> mzero
    parseRate r = case C.uncons r of
      Just ('@', s) -> do
        t <- parseRead s
        guard (t > 0.0)
        guard (t < 1.0)
        return t
      _ -> mzero

parseRead :: (MonadPlus m, Read a) => ByteString -> m a
parseRead = maybe mzero return . readMaybe . C.unpack

parseInt :: (MonadPlus m) => ByteString -> m Int
parseInt bs = case C.readInt bs of
  Just (i, bs') | B.null bs' -> return i
  _ -> mzero

parseNatural :: (MonadPlus m) => ByteString -> m Int
parseNatural bs = case C.readInt bs of
  Just (i, bs') | B.null bs' -> guard (0 <= i) >> return i
  _ -> mzero