statsd-rupp-0.5.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
-- Copyright : (c) 2023 JP Rupp
-- License : BSD3
-- Maintainer : jprupp@protonmail.ch
-- Stability : experimental
-- Portability : POSIX
--
-- Implementation details of "System.Metrics.StatsD". This module is exposed for
-- testing and for advanced use. Its API may change between minor versions.
--
-- Metrics are kept in a 'HashMap' from key to 'Store' inside a 'TVar'. Every
-- reading updates the aggregated 'MetricData' of its metric and increments a
-- per-metric counter, which is used to decide which samples to send when
-- sampling. A background loop ('statsLoop') periodically turns the aggregated
-- data into 'Report's, sends them, and resets the data ('flush').
--
-- Keeping every metric in one 'TVar' means a reading is a single short
-- transaction, and a flush can take a consistent picture of all metrics at once
-- and reset them atomically, so no reading is counted twice or lost between
-- intervals.
--
-- The parser ('parseReport') is not used by the client. It exists so that the
-- tests can check that every line the client formats reads back as the same
-- report.
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,
)
-- | Handle to a StatsD client. Create one with
-- 'System.Metrics.StatsD.withStats'. It can be shared between threads.
data Stats = Stats
{ -- | registered metrics and their aggregated data
metrics :: !(TVar Metrics),
-- | UDP socket connected to the StatsD server
socket :: !Socket,
-- | settings derived from the 'StatConfig'
params :: !StatParams
}
-- | Settings derived from a 'StatConfig' by 'newParams', with the prefixes
-- already joined and encoded.
data StatParams = StatParams
{ -- | namespace followed by a dot, or empty; prepended to samples
pfx :: !ByteString,
-- | full prefix for counter statistics, ending in a dot
pfxCounter :: !ByteString,
-- | full prefix for timing statistics, ending in a dot
pfxTimer :: !ByteString,
-- | full prefix for gauge statistics, ending in a dot
pfxGauge :: !ByteString,
-- | full prefix for set statistics, ending in a dot
pfxSet :: !ByteString,
-- | flush interval in milliseconds
flush :: !Int,
-- | whether aggregated statistics are kept and reported
stats :: !Bool,
-- | whether individual samples are reported
samples :: !Bool,
-- | percentiles to compute for timings, always including 100
percentiles :: ![Int],
-- | whether to append a newline to each line sent
newline :: !Bool
}
-- | Client configuration. Start from 'System.Metrics.StatsD.defStatConfig' and
-- change the fields you need.
data StatConfig = StatConfig
{ -- | aggregate samples and send statistics every 'flushInterval'
reportStats :: !Bool,
-- | send every sample (subject to sampling) as it happens
reportSamples :: !Bool,
-- | prefix for every metric name; empty for none
namespace :: !String,
-- | prefix for aggregated statistics, after the namespace
prefixStats :: !String,
-- | prefix for counter statistics, after 'prefixStats'
prefixCounter :: !String,
-- | prefix for timing statistics, after 'prefixStats'
prefixTimer :: !String,
-- | prefix for gauge statistics, after 'prefixStats'
prefixGauge :: !String,
-- | prefix for set statistics, after 'prefixStats'
prefixSet :: !String,
-- | host name or IP address of the StatsD server
statsdServer :: !String,
-- | UDP port of the StatsD server
statsdPort :: !Int,
-- | milliseconds between flushes of aggregated statistics; must be
-- positive
flushInterval :: !Int,
-- | percentiles to compute for timings, each strictly between 0 and 100
timingPercentiles :: ![Int],
-- | append a newline to each line sent, as some servers require
appendNewline :: !Bool
}
deriving (Show, Read, Eq, Ord)
-- | Aggregated data of a metric during the current flush interval.
data MetricData
= -- | sum of increments
CounterData !Int
| -- | current value
GaugeData !Int
| -- | timings recorded, most recent first
TimingData ![Int]
| -- | distinct elements seen
SetData !(HashSet ByteString)
-- | State of a registered metric.
data Store = Store
{ -- | number of readings so far, used for sampling
index :: !Int,
-- | aggregated data, or 'Nothing' if statistics are not reported
dat :: !(Maybe MetricData)
}
-- | All registered metrics, by key.
type Metrics = HashMap ByteString Store
-- | A single reading, or the value of a report.
data Value
= -- | counter increment (@|c@)
Counter !Int
| -- | gauge value; if the flag is 'True' it is a relative change and is sent
-- with an explicit sign (@|g@)
Gauge !Int !Bool
| -- | timing in milliseconds (@|ms@)
Timing !Int
| -- | set element (@|s@)
Set !ByteString
deriving (Eq, Ord, Show, Read)
-- | A line to send to the StatsD server, in the form
-- @key:value|type[|\@rate]@.
data Report = Report
{ -- | full metric name
key :: !ByteString,
-- | value and type
value :: !Value,
-- | sampling rate between 0 and 1; only sent if below 1
rate :: !Double
}
deriving (Eq, Ord, Show, Read)
-- | A reading together with what is needed to decide whether to send it.
data Sample = Sample
{ -- | metric key, without namespace
key :: !ByteString,
-- | the reading
value :: !Value,
-- | send one in this many readings; @0@ to send none
sampling :: !Int,
-- | number of readings of this metric so far, including this one
index :: !Int
}
deriving (Eq, Ord, Show, Read)
-- | Handle to a counter. Create with 'System.Metrics.StatsD.newStatCounter'.
data StatCounter = StatCounter
{ -- | client the counter belongs to
stats :: !Stats,
-- | metric key
key :: !ByteString,
-- | sampling rate for individual samples
sampling :: !Int
}
-- | Handle to a gauge. Create with 'System.Metrics.StatsD.newStatGauge'.
data StatGauge = StatGauge
{ -- | client the gauge belongs to
stats :: !Stats,
-- | metric key
key :: !ByteString
}
-- | Handle to a timing metric. Create with
-- 'System.Metrics.StatsD.newStatTiming'.
data StatTiming = StatTiming
{ -- | client the timing belongs to
stats :: !Stats,
-- | metric key
key :: !ByteString,
-- | sampling rate for individual samples
sampling :: !Int
}
-- | Handle to a set. Create with 'System.Metrics.StatsD.newStatSet'.
data StatSet = StatSet
{ -- | client the set belongs to
stats :: !Stats,
-- | metric key
key :: !ByteString
}
-- | Add a metric with its initial data to the map. The data is only kept if
-- statistics are reported.
addMetric :: StatParams -> ByteString -> MetricData -> Metrics -> Metrics
addMetric params key dat =
HashMap.insert key $
Store 0 $
if params.stats then Just dat else Nothing
-- | Register a metric. Throws an 'IOError' if the key is invalid or already
-- registered.
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
-- | A key is valid if it is not empty and only contains ASCII letters, digits,
-- @.@, @_@ and @-@.
validateKey :: ByteString -> Bool
validateKey t = not (C.null t) && C.all valid t
where
valid c = elem c ("._-" :: [Char]) || isAscii c && isAlphaNum c
-- | Apply a reading to the aggregated data of a metric and increment its reading
-- count. Relative gauge changes saturate at 'maxBound' and never go below zero.
-- Calls 'error' if the reading does not match the type of the metric.
--
-- Gauges saturate rather than wrap around because a gauge that suddenly jumps
-- from a huge value to zero, or below, would be worse than one stuck at its
-- maximum.
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"
-- | Apply a reading and return the updated reading count of the metric, or
-- @0@ if the metric does not exist.
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
-- | Record a reading and, if samples are reported, send it subject to the
-- sampling rate.
processSample ::
(MonadIO m) => Stats -> Int -> ByteString -> Value -> m ()
processSample stats sampling key val = do
idx <- atomically $ newReading stats key val
when stats.params.samples $
submit stats $
Sample key val sampling idx
-- | Create an empty set of metrics.
newMetrics :: (MonadIO m) => m (TVar Metrics)
newMetrics = newTVarIO HashMap.empty
-- | Validate a configuration and derive its parameters. Calls 'error' if the
-- configuration is invalid.
newParams :: StatConfig -> StatParams
newParams cfg
| valid =
StatParams
{ pfx = pfx_namespace,
pfxCounter = full_pfx_stats <> counters <> ".",
pfxGauge = full_pfx_stats <> gauges <> ".",
pfxTimer = full_pfx_stats <> timers <> ".",
pfxSet = full_pfx_stats <> sets <> ".",
newline = cfg.appendNewline,
stats = cfg.reportStats,
samples = cfg.reportSamples,
percentiles = 100 : nub cfg.timingPercentiles,
flush = cfg.flushInterval
}
| otherwise = error "StatsD config invalid"
where
namespace = C.pack cfg.namespace
pfx_stats = C.pack cfg.prefixStats
gauges = C.pack cfg.prefixGauge
counters = C.pack cfg.prefixCounter
timers = C.pack cfg.prefixTimer
sets = C.pack cfg.prefixSet
valid =
all validateKey [pfx_stats, gauges, counters, timers, sets]
&& bool (validateKey namespace) True (C.null namespace)
&& cfg.flushInterval > 0
&& all (\pc -> pc > 0 && 100 > pc) cfg.timingPercentiles
pfx_namespace =
if C.null namespace
then ""
else namespace <> "."
full_pfx_stats = pfx_namespace <> pfx_stats <> "."
-- | Build a client handle from its parts.
newStats :: StatConfig -> TVar Metrics -> Socket -> Stats
newStats cfg metrics socket =
Stats
{ metrics = metrics,
socket = socket,
params = newParams cfg
}
-- | Flush statistics every flush interval, forever.
statsLoop :: (MonadIO m) => Stats -> m ()
statsLoop stats = forever $ do
threadDelay (stats.params.flush * 1000)
statsFlush stats
-- | Send the statistics of all metrics and reset them.
statsFlush :: (MonadIO m) => Stats -> m ()
statsFlush stats = do
mapM_ (send stats)
=<< atomically
(stateTVar stats.metrics (flushStats stats.params))
-- | Compute the reports for all metrics and the metrics reset for the next
-- interval.
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)
-- | Join non-empty key parts with dots.
catKey :: [ByteString] -> ByteString
catKey = C.intercalate "." . filter (not . C.null)
-- | Reports for the aggregated data of a metric.
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
-- | Sorted timings with precomputed running totals, so that statistics for
-- any percentile can be read off a prefix of the vectors.
data TimingStats = TimingStats
{ -- | timings in ascending order
timings :: !(Vector Int),
-- | running sums of the timings
cumsums :: !(Vector Int),
-- | running sums of the squares of the timings
cumsquares :: !(Vector Int)
}
deriving (Eq, Ord, Show, Read)
-- | Sort timings and compute their running totals.
--
-- Sorting once and keeping running sums lets every percentile's count, sum,
-- mean and sum of squares be read from a prefix of the vectors in constant time,
-- instead of recomputing them for each percentile.
makeTimingStats :: [Int] -> TimingStats
makeTimingStats timings =
TimingStats
{ timings = V.fromList sorted,
cumsums = V.fromList (cumulativeSums sorted),
cumsquares = V.fromList (cumulativeSquares sorted)
}
where
sorted = sort timings
-- | Reports for a timing metric, for every configured percentile.
timingReports :: StatParams -> ByteString -> [Int] -> [Report]
timingReports params key timings =
concatMap (timingStats params key tstats) params.percentiles
where
tstats = makeTimingStats timings
-- | Keep only the lowest given percentage of the 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
-- | Suffix for statistics of a percentile: empty for 100, @_N@ otherwise.
percentileSuffix :: Int -> ByteString
percentileSuffix pc
| pc == 100 = ""
| otherwise = C.pack $ printf "_%d" pc
-- | Turn a count over the flush interval into a count per second.
computeRate :: StatParams -> Int -> Int
computeRate params i = i * 1000 `div` params.flush
-- | Integer mean of the timings. Must not be called on empty stats.
mean :: TimingStats -> Int
mean ts = V.last ts.cumsums `div` length ts.timings
-- | Reports for a timing metric at one percentile.
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}
-- | Running sums of a list.
cumulativeSums :: (Num a) => [a] -> [a]
cumulativeSums = scanl1 (+)
-- | Running sums of the squares of a list.
cumulativeSquares :: (Num a) => [a] -> [a]
cumulativeSquares = scanl1 (+) . map (\x -> x * x)
-- | Population standard deviation of the timings, rounded.
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 of the timings, or @0@ if there are none.
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
-- | Reset aggregated data for the next interval. Gauges keep their value.
flush :: MetricData -> MetricData
flush (CounterData _) = CounterData 0
flush (GaugeData g) = GaugeData g
flush (TimingData _) = TimingData []
flush (SetData _) = SetData HashSet.empty
-- | Turn a sample into a report if the sampling rate selects it.
toReport :: StatParams -> Sample -> Maybe Report
toReport params sample
| sample.sampling > 0 && sample.index `mod` sample.sampling == 0 =
Just
Report
{ key = params.pfx <> sample.key,
value = sample.value,
rate = 1.0 / fromIntegral sample.sampling
}
| otherwise = Nothing
-- | Encode a report in the StatsD line protocol.
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"
-- | Send a sample if the sampling rate selects it.
submit :: (MonadIO m) => Stats -> Sample -> m ()
submit stats sample =
forM_ (toReport stats.params sample) (send stats)
-- | Send a report, ignoring network errors.
--
-- Errors are ignored on purpose: the alternative, failing the code that recorded
-- a metric because the network is down, is never what a program wants.
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)
-- | Resolve a host and port and create a UDP socket connected to it. Calls
-- 'error' if the host cannot be resolved.
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
-- | Parse a line in the StatsD protocol. The inverse of 'formatReport'.
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
-- | Parse a value with its 'Read' instance.
parseRead :: (MonadPlus m, Read a) => ByteString -> m a
parseRead = maybe mzero return . readMaybe . C.unpack
-- | Parse a signed integer that takes up the whole string.
parseInt :: (MonadPlus m) => ByteString -> m Int
parseInt bs = case C.readInt bs of
Just (i, bs') | B.null bs' -> return i
_ -> mzero
-- | Parse a non-negative integer that takes up the whole string.
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