packages feed

instrument-0.6.1.0: src/Instrument/Worker.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Instrument.Worker
  ( initWorkerCSV,
    initWorkerCSV',
    initWorkerGraphite,
    initWorkerGraphite',
    work,
    initWorker,
    AggProcess (..),

    -- * Configuring agg processes
    AggProcessConfig (..),
    standardQuantiles,
    noQuantiles,
    quantileMap,
    defAggProcessConfig,

    -- * Exported for testing
    expandDims,
  )
where

-------------------------------------------------------------------------------
import Control.Error
import Control.Monad
import Control.Monad.IO.Class
import qualified Data.ByteString.Char8 as B
import Data.CSV.Conduit
import Data.Conduit (runConduit, (.|))
import qualified Data.Conduit.List as CL
import Data.Default
import qualified Data.Map as M
import qualified Data.SafeCopy as SC
import Data.Semigroup as Semigroup
import Data.Serialize
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.IO as T
import qualified Data.Vector.Unboxed as V
import Database.Redis as R hiding (decode)
-------------------------------------------------------------------------------
import Instrument.Client
  ( packetsKey,
    stripTimerPrefix,
    timerMetricName,
  )
import qualified Instrument.Measurement as TM
import Instrument.Types
import Instrument.Utils
import Network.Socket as N
import qualified Statistics.Quantile as Q
import Statistics.Sample
import System.IO
import System.Posix

-------------------------------------------------------------------------------

-------------------------------------------------------------------------------

-- | A CSV backend to store aggregation results in a CSV
initWorkerCSV ::
  ConnectInfo ->
  -- | Target file name
  FilePath ->
  -- | Aggregation period / flush interval in seconds
  Int ->
  AggProcessConfig ->
  IO ()
initWorkerCSV conn fp n cfg =
  initWorker "CSV Worker" conn n =<< initWorkerCSV' fp cfg

-------------------------------------------------------------------------------

-- | Create an AggProcess that dumps to CSV. Use this to compose with
-- other AggProcesses
initWorkerCSV' ::
  -- | Target file name
  FilePath ->
  AggProcessConfig ->
  IO AggProcess
initWorkerCSV' fp cfg = do
  !res <- fileExist fp
  !h <- openFile fp AppendMode
  hSetBuffering h LineBuffering
  unless res $
    T.hPutStrLn h $ rowToStr defCSVSettings . M.keys $ aggToCSV def
  return $ putAggregateCSV h cfg

-------------------------------------------------------------------------------

-- | Initialize a Graphite backend
initWorkerGraphite ::
  -- | Redis connection
  ConnectInfo ->
  -- | Aggregation period / flush interval in seconds
  Int ->
  -- | Graphite host
  HostName ->
  -- | Graphite port
  Int ->
  AggProcessConfig ->
  IO ()
initWorkerGraphite conn n server port cfg =
  initWorker "Graphite Worker" conn n =<< initWorkerGraphite' server port cfg

-------------------------------------------------------------------------------

-- | Crete an AggProcess that dumps to graphite. Use this to compose
-- with other AggProcesses
initWorkerGraphite' ::
  -- | Graphite host
  HostName ->
  -- | Graphite port
  Int ->
  AggProcessConfig ->
  IO AggProcess
initWorkerGraphite' server port cfg = do
  addr <- resolve server (fromIntegral port)
  sock <- open addr
  h <- N.socketToHandle sock ReadWriteMode
  hSetBuffering h LineBuffering
  return $ putAggregateGraphite h cfg
  where
    portNumberToServiceName :: N.PortNumber -> N.ServiceName
    portNumberToServiceName = show
    resolve host portNumber = do
      let hints = N.defaultHints {N.addrSocketType = N.Stream}
      addr : _ <-
        N.getAddrInfo
          (Just hints)
          (Just host)
          (Just (portNumberToServiceName portNumber))
      return addr
    open addr = do
      sock <-
        N.socket
          (N.addrFamily addr)
          (N.addrSocketType addr)
          (N.addrProtocol addr)
      N.connect sock (N.addrAddress addr)
      return sock

-------------------------------------------------------------------------------

-- | Generic utility for making worker backends. Will retry
-- indefinitely with exponential backoff.
initWorker :: String -> ConnectInfo -> Int -> AggProcess -> IO ()
initWorker wname conn n f = do
  p <- createInstrumentPool conn
  indefinitely' $ work p n f
  where
    indefinitely' = indefinitely wname (seconds n)

-------------------------------------------------------------------------------

-- | Extract statistics out of the given sample for this flush period
mkStats :: Set.Set Quantile -> Sample -> Stats
mkStats qs s =
  Stats
    { smean = mean s,
      ssum = V.sum s,
      scount = V.length s,
      smax = V.maximum s,
      smin = V.minimum s,
      srange = range s,
      sstdev = stdDev s,
      sskewness = skewness s,
      skurtosis = kurtosis s,
      squantiles = quantiles
    }
  where
    quantiles = M.fromList (mkQ 100 . quantile <$> Set.toList qs)
    mkQ mx i = (i, Q.weightedAvg i mx s)

-------------------------------------------------------------------------------

-- | Go over all pending stats buffers in redis.
work :: R.Connection -> Int -> AggProcess -> IO ()
work r n f = runRedis r $ do
  dbg "entered work block"
  estimate <- either (const 0) id <$> scard packetsKey
  runConduit $
    CL.unfoldM nextKey estimate
      .| CL.mapM_ (processSampler n f)
  where
    nextKey estRemaining
      | estRemaining > 0 = do
        mk <- spop packetsKey
        return $ case mk of
          Right (Just k) -> Just (k, estRemaining - 1)
          _ -> Nothing
      | otherwise = return Nothing

-------------------------------------------------------------------------------
processSampler ::
  -- | Flush interval - determines resolution
  Int ->
  -- | What to do with aggregation results
  AggProcess ->
  -- | Redis buffer for this metric
  B.ByteString ->
  Redis ()
processSampler n (AggProcess cfg f) k = do
  packets <- popLAll k
  case packets of
    [] -> return ()
    _ -> do
      let nm = spName . head $ packets
          -- with and without timer prefix
          qs = quantilesFn (stripTimerPrefix nm) <> quantilesFn (timerMetricName nm)
          byDims :: M.Map Dimensions [SubmissionPacket]
          byDims = collect packets spDimensions id
          mkAgg xs =
            case spPayload $ head xs of
              Samples _ ->
                AggStats . mkStats qs . V.fromList
                  . concatMap (unSamples . spPayload)
                  $ xs
              Counter _ ->
                AggCount . sum
                  . map (unCounter . spPayload)
                  $ xs
      t <- (fromIntegral . (* n) . (`div` n) . round) `liftM` liftIO TM.getTime
      let aggs = map mkDimsAgg $ M.toList $ expandDims $ byDims
          mkDimsAgg (dims, ps) = Aggregated t nm (mkAgg ps) dims
      mapM_ f aggs
      return ()
  where
    quantilesFn = metricQuantiles cfg

-------------------------------------------------------------------------------

-- | Take a map of packets by dimensions and *add* aggregations of the
-- existing dims that isolate each distinct dimension/dimensionvalue
-- pair + one more entry with an empty dimension set that aggregates
-- the whole thing.
-- worked example:
--
-- Given:
-- { {d1=>d1v1,d2=>d2v1} => p1
-- , {d1=>d1v1,d2=>d2v2} => p2
-- }
-- Produces:
-- { {d1=>d1v1,d2=>d2v1} => p1
-- , {d1=>d1v1,d2=>d2v2} => p2
-- , {d1=>d1v1} => p1 + p2
-- , {d2=>d2v1} => p1
-- , {d2=>d2v2} => p2
-- , {} => p1 + p2
-- }
expandDims ::
  forall packets.
  (Monoid packets, Eq packets) =>
  M.Map Dimensions packets ->
  M.Map Dimensions packets
expandDims m =
  -- left-biased so technically if we have anything occupying the aggregated spots, leave them be
  m <> additions <> fullAggregation
  where
    distinctPairs :: Set.Set (DimensionName, DimensionValue)
    distinctPairs = Set.fromList (mconcat (M.toList <$> M.keys m))
    additions = foldMap mkIsolatedMap distinctPairs
    mkIsolatedMap :: (DimensionName, DimensionValue) -> M.Map Dimensions packets
    mkIsolatedMap dPair =
      let matches = snd <$> filter ((== dPair) . fst) mFlat
       in if matches == mempty
            then mempty
            else M.singleton (uncurry M.singleton dPair) (mconcat matches)
    mFlat :: [((DimensionName, DimensionValue), packets)]
    mFlat =
      [ ((dn, dv), packets)
        | (dimensionsMap, packets) <- M.toList m,
          (dn, dv) <- M.toList dimensionsMap
      ]
    -- All packets across any combination of dimensions
    fullAggregation = M.singleton mempty (mconcat (M.elems m))

-- | A function that does something with the aggregation results. Can
-- implement multiple backends simply using this. Note that Semigroup and Monoid instances are provided for defaulting and combining agg processes.
data AggProcess = AggProcess
  { apConfig :: AggProcessConfig,
    apProc :: Aggregated -> Redis ()
  }

instance Semigroup.Semigroup AggProcess where
  (AggProcess cfg1 prc1) <> (AggProcess cfg2 prc2) =
    AggProcess (cfg1 <> cfg2) (\agg -> prc1 agg >> prc2 agg)

instance Monoid AggProcess where
  mempty = AggProcess mempty (const (pure ()))
  mappend = (<>)

-------------------------------------------------------------------------------

-- | General configuration for agg processes. Defaulted with 'def',
-- 'defAggProcessConfig', and 'mempty'. Configurations can be combined
-- with (<>) from Monoid or Semigroup.
data AggProcessConfig = AggProcessConfig
  { -- | What quantiles should we calculate for any given metric, if
    -- any? We offer some common patterns for this in 'quantileMap',
    -- 'standardQuantiles', and 'noQuantiles'.
    metricQuantiles :: MetricName -> Set.Set Quantile
  }

instance Semigroup AggProcessConfig where
  AggProcessConfig f1 <> AggProcessConfig f2 =
    let f3 = f1 <> f2
     in AggProcessConfig f3

instance Monoid AggProcessConfig where
  mempty = AggProcessConfig mempty
  mappend = (<>)

-- | Uses 'standardQuantiles'.
defAggProcessConfig :: AggProcessConfig
defAggProcessConfig = AggProcessConfig standardQuantiles

instance Default AggProcessConfig where
  def = defAggProcessConfig

-- | Regardless of metric, produce no quantiles.
noQuantiles :: MetricName -> Set.Set Quantile
noQuantiles = const mempty

-- | This is usually a good, comprehensive default. Produces quantiles
-- 10,20,30,40,50,60,70,80,90,99. *Note:* for some backends like
-- cloudwatch, each quantile produces an additional metric, so you
-- should probably consider using something more limited than this.
standardQuantiles :: MetricName -> Set.Set Quantile
standardQuantiles _ =
  Set.fromList [Q 10, Q 20, Q 30, Q 40, Q 50, Q 60, Q 70, Q 80, Q 90, Q 99]

-- | If you have a fixed set of metric names, this is often a
-- convenient way to express quantiles-per-metric.
quantileMap ::
  M.Map MetricName (Set.Set Quantile) ->
  -- | What to return on miss
  Set.Set Quantile ->
  (MetricName -> Set.Set Quantile)
quantileMap m qdef mn = fromMaybe qdef (M.lookup mn m)

-------------------------------------------------------------------------------

-- | Store aggregation results in a CSV file
putAggregateCSV :: Handle -> AggProcessConfig -> AggProcess
putAggregateCSV h cfg = AggProcess cfg $ \agg ->
  let d = rowToStr defCSVSettings $ aggToCSV agg
   in liftIO $ T.hPutStrLn h d

typePrefix :: AggPayload -> T.Text
typePrefix AggStats {} = "samples"
typePrefix AggCount {} = "counts"

-------------------------------------------------------------------------------

-- | Push data into a Graphite database using the plaintext protocol
putAggregateGraphite :: Handle -> AggProcessConfig -> AggProcess
putAggregateGraphite h cfg = AggProcess cfg $ \agg ->
  let (ss, ts) = mkStatsFields agg
      -- Expand dimensions into one datum per dimension pair as the group
      mkLines (m, val) = for (M.toList (aggDimensions agg)) $ \(DimensionName dimName, DimensionValue dimVal) ->
        T.concat
          [ "inst.",
            typePrefix (aggPayload agg),
            ".",
            T.pack (metricName (aggName agg)),
            ".",
            m,
            ".",
            dimName,
            ".",
            dimVal,
            " ",
            val,
            " ",
            ts
          ]
   in liftIO $ mapM_ (mapM_ (T.hPutStrLn h) . mkLines) ss

-------------------------------------------------------------------------------

-- | Pop all keys in a redis List
popLAll :: (Serialize a, SC.SafeCopy a) => B.ByteString -> Redis [a]
popLAll k = do
  res <- popLMany k 100
  case res of
    [] -> return res
    _ -> (res ++) `liftM` popLAll k

-------------------------------------------------------------------------------

-- | Pop up to N items from a queue. It will pop from left and preserve order.
popLMany :: (Serialize a, SC.SafeCopy a) => B.ByteString -> Int -> Redis [a]
popLMany k n = do
  res <- replicateM n pop
  case sequence res of
    Left _ -> return []
    Right xs -> return $ mapMaybe conv $ catMaybes xs
  where
    pop = R.lpop k
    conv x = hush $ decodeCompress x

-------------------------------------------------------------------------------

-- | Need to pull in a debugging library here.
dbg :: (Monad m) => String -> m ()
dbg _ = return ()

-- ------------------------------------------------------------------------------
-- dbg :: (MonadIO m) => String -> m ()
-- dbg s = debug $ "Instrument.Worker: " ++ s

-------------------------------------------------------------------------------

-- | Expand count aggregation to have the full columns
aggToCSV :: Aggregated -> M.Map T.Text T.Text
aggToCSV agg@Aggregated {..} = els <> defFields <> dimFields
  where
    els :: MapRow T.Text
    els =
      M.fromList $
        ("metric", T.pack (metricName aggName)) :
        ("timestamp", ts) :
        fields
    (fields, ts) = mkStatsFields agg
    defFields = M.fromList $ fst $ mkStatsFields $ agg {aggPayload = (AggStats def)}
    dimFields = M.fromList [(k, v) | (DimensionName k, DimensionValue v) <- M.toList aggDimensions]

-------------------------------------------------------------------------------

-- | Get agg results into a form ready to be output
mkStatsFields :: Aggregated -> ([(T.Text, T.Text)], T.Text)
mkStatsFields Aggregated {..} = (els, ts)
  where
    els =
      case aggPayload of
        AggStats Stats {..} ->
          [ ("mean", formatDecimal 6 False smean),
            ("count", showT scount),
            ("max", formatDecimal 6 False smax),
            ("min", formatDecimal 6 False smin),
            ("srange", formatDecimal 6 False srange),
            ("stdDev", formatDecimal 6 False sstdev),
            ("sum", formatDecimal 6 False ssum),
            ("skewness", formatDecimal 6 False sskewness),
            ("kurtosis", formatDecimal 6 False skurtosis)
          ]
            ++ (map mkQ $ M.toList squantiles)
        AggCount i ->
          [("count", showT i)]

    mkQ (k, v) = (T.concat ["percentile_", showT k], formatDecimal 6 False v)
    ts = formatInt aggTS