packages feed

eventlog-live-otelcol-0.6.0.0: src/GHC/Eventlog/Live/Otelcol/Stats.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : GHC.Eventlog.Live.Otelcol.Stats
Description : The implementation of @eventlog-live-otelcol@.
Stability   : experimental
Portability : portable
-}
module GHC.Eventlog.Live.Otelcol.Stats (
  EventCount,
  eventCountTick,
  Stat (..),
  processStats,
)
where

import Control.Exception (Exception (..))
import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO (..))
import Data.Default (Default (..))
import Data.Foldable (for_)
import Data.Int (Int64)
import Data.Machine (ProcessT, await, construct, repeatedly, yield)
import Data.Monoid (First (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Void (Void)
import GHC.Eventlog.Live.Data.Severity (Severity (..))
import GHC.Eventlog.Live.Logger (Logger, writeLog)
import GHC.Eventlog.Live.Machine.Core (Tick)
import GHC.Eventlog.Live.Machine.Core qualified as M
import GHC.Eventlog.Live.Otelcol.Exporter.Logs (ExportLogsResult (..))
import GHC.Eventlog.Live.Otelcol.Exporter.Metrics (ExportMetricsResult (..))
import GHC.Eventlog.Live.Otelcol.Exporter.Profiles (ExportProfileResult (..))
import GHC.Eventlog.Live.Otelcol.Exporter.Traces (ExportTraceResult (..))
import GHC.Records (HasField (..))
import StrictList qualified as Strict
import System.Console.ANSI (hNowSupportsANSI)
import System.Console.ANSI qualified as ANSI
import System.IO qualified as IO
import Text.Layout.Table qualified as TBL
import Text.Printf (printf)

{- |
This type represents a count of input events.
-}
newtype EventCount
  = EventCount {value :: Int64}
  deriving (Show)

{- |
Count the number of events seen between each tick.
-}
eventCountTick :: (Monad m) => ProcessT m (Tick a) (Tick EventCount)
eventCountTick = construct $ go 0
 where
  go acc =
    await >>= \case
      M.Tick -> yield (M.Item $ EventCount acc) >> yield M.Tick >> go 0
      M.Item{} -> go (acc + 1)

{- |
This type represents the various stats produced by the pipeline.
-}
data Stat
  = EventCountStat !EventCount
  | ExportLogsResultStat !ExportLogsResult
  | ExportMetricsResultStat !ExportMetricsResult
  | ExportTraceResultStat !ExportTraceResult
  | ExportProfileResultStat !ExportProfileResult
  deriving (Show)

{- |
Internal helper.
This type represents the aggregate stats kept by the stats processor.
-}
data Stats = Stats
  { eventCounts :: Row
  , exportedLogRecords :: Row
  , rejectedLogRecords :: Row
  , exportedDataPoints :: Row
  , rejectedDataPoints :: Row
  , exportedSpans :: Row
  , rejectedSpans :: Row
  , exportedProfiles :: Row
  , rejectedProfiles :: Row
  , errors :: !(Strict.List Text)
  , displayedLines :: !(First Int)
  }
  deriving (Show)

{- |
Internal helper.
This type represents a single row of statistics.
-}
data Row = Row
  { total :: !Int64
  , peakRatePerBatch :: !Double
  , window :: !(Strict.List Int64)
  }
  deriving (Show)

instance HasField "ratePerBatch" Row Double where
  getField :: Row -> Double
  getField row = ratePerBatch row.window

{- |
Internal helper.
Computes the rate per batch from a window.
-}
ratePerBatch :: Strict.List Int64 -> Double
ratePerBatch window =
  let !n = length window
   in if n <= 0 then 0 else sum (realToFrac <$> window) / fromIntegral n

{- |
Internal helper.
Create a singleton `Row`.
-}
singletonRow :: Int64 -> Row
singletonRow total = Row{..}
 where
  peakRatePerBatch = realToFrac total
  window = singletonStrictList total

{- |
Internal helper.
This implements the left-biased union of rows.
In @unionRow new old@, the @new@ argument should contain the latest data.
-}
unionRow :: Int -> Row -> Row -> Row
unionRow windowSize new old = Row{..}
 where
  total = new.total + old.total
  window = Strict.take windowSize (new.window <> old.window)
  peakRatePerBatch = maximum [new.peakRatePerBatch, old.peakRatePerBatch, ratePerBatch window]

{- |
Internal helper.
This instance implements the left-biased union of stats.
In @new <> old@, the @new@ argument should contain the latest data.
-}
unionStats :: Int -> Stats -> Stats -> Stats
unionStats windowSize new old = Stats{..}
 where
  eventCounts = unionRow windowSize new.eventCounts old.eventCounts
  exportedLogRecords = unionRow windowSize new.exportedLogRecords old.exportedLogRecords
  rejectedLogRecords = unionRow windowSize new.rejectedLogRecords old.rejectedLogRecords
  exportedDataPoints = unionRow windowSize new.exportedDataPoints old.exportedDataPoints
  rejectedDataPoints = unionRow windowSize new.rejectedDataPoints old.rejectedDataPoints
  exportedSpans = unionRow windowSize new.exportedSpans old.exportedSpans
  rejectedSpans = unionRow windowSize new.rejectedSpans old.rejectedSpans
  exportedProfiles = unionRow windowSize new.exportedProfiles old.exportedProfiles
  rejectedProfiles = unionRow windowSize new.rejectedProfiles old.rejectedProfiles
  errors = Strict.take windowSize (new.errors <> old.errors)
  displayedLines = new.displayedLines <> old.displayedLines

{- |
Internal helper.
Construct a `Stats` object from an `EventCount`.
-}
fromEventCount :: EventCount -> Stats
fromEventCount eventCount = def{eventCounts = singletonRow eventCount.value}

{- |
Internal helper.
Construct a `Stats` object from an `ExportLogsResult`.
-}
fromExportLogsResult :: ExportLogsResult -> Stats
fromExportLogsResult exportLogsResult =
  def
    { exportedLogRecords = singletonRow exportLogsResult.exportedLogRecords
    , rejectedLogRecords = singletonRow exportLogsResult.rejectedLogRecords
    , errors = maybeToStrictList $ T.pack . displayException <$> exportLogsResult.maybeSomeException
    }

{- |
Internal helper.
Construct a `Stats` object from an `ExportMetricsResult`.
-}
fromExportMetricsResult :: ExportMetricsResult -> Stats
fromExportMetricsResult exportMetricResult =
  def
    { exportedDataPoints = singletonRow exportMetricResult.exportedDataPoints
    , rejectedDataPoints = singletonRow exportMetricResult.rejectedDataPoints
    , errors = maybeToStrictList $ T.pack . displayException <$> exportMetricResult.maybeSomeException
    }

{- |
Internal helper.
Construct a `Stats` object from an `ExportTraceResult`.
-}
fromExportTraceResult :: ExportTraceResult -> Stats
fromExportTraceResult exportTracesResult =
  def
    { exportedSpans = singletonRow exportTracesResult.exportedSpans
    , rejectedSpans = singletonRow exportTracesResult.rejectedSpans
    , errors = maybeToStrictList $ T.pack . displayException <$> exportTracesResult.maybeSomeException
    }

{- |
Internal helper.
Construct a `Stats` object from an `ExportProfileResult`.
-}
fromExportProfileResult :: ExportProfileResult -> Stats
fromExportProfileResult exportProfileResult =
  def
    { exportedProfiles = singletonRow exportProfileResult.exportedProfiles
    , rejectedProfiles = singletonRow exportProfileResult.rejectedProfiles
    , errors = maybeToStrictList $ T.pack . displayException <$> exportProfileResult.maybeSomeException
    }

{- |
Internal helper.
Construct a singleton `Strict.List`.
-}
singletonStrictList :: a -> Strict.List a
singletonStrictList = (`Strict.Cons` Strict.Nil)

{- |
Internal helper.
Variant of `Data.Maybe.maybeToList` for `Strict.List`.
-}
maybeToStrictList :: Maybe a -> Strict.List a
maybeToStrictList = maybe Strict.Nil singletonStrictList

{- |
Internal helper.
This instance implements the empty row.
-}
instance Default Row where
  def :: Row
  def = Row{..}
   where
    total = 0
    peakRatePerBatch = 0
    window = Strict.Nil

{- |
Internal helper.
This implements the empty stats.
-}
instance Default Stats where
  def :: Stats
  def = Stats{..}
   where
    eventCounts = def
    exportedLogRecords = def
    rejectedLogRecords = def
    exportedDataPoints = def
    rejectedDataPoints = def
    exportedSpans = def
    rejectedSpans = def
    exportedProfiles = def
    rejectedProfiles = def
    errors = mempty
    displayedLines = First Nothing

{- |
Process and display stats.

__Warning:__ This machine prints to stdout and is intended to be the /only/ function printing to stdout.
-}
processStats ::
  Logger IO ->
  Bool ->
  Double ->
  Int ->
  ProcessT IO Stat Void
processStats logger stats eventlogFlushIntervalS windowSize
  | stats =
      -- If --stats is ENABLED, maintain and display `Stats`.
      let go stats0 =
            await >>= \stat -> do
              -- Log the incoming `Stat` value.
              liftIO $ logStat logger stat
              -- Maintain and display the `Stats`.
              let stats1 = updateStats windowSize stats0 stat
              stats2 <- liftIO $ displayStats logger eventlogFlushIntervalS stats1
              go stats2
       in construct $ go def
  | otherwise =
      -- If --stats is DISABLED, log all incoming `Stat` values.
      repeatedly $
        await >>= \stat ->
          liftIO $ logStat logger stat

{- |
Internal helper.
Update the current stats based on new input.
-}
updateStats :: Int -> Stats -> Stat -> Stats
updateStats windowSize old = \case
  EventCountStat eventCount -> unionStats windowSize (fromEventCount eventCount) old
  ExportLogsResultStat exportLogsResults -> unionStats windowSize (fromExportLogsResult exportLogsResults) old
  ExportMetricsResultStat exportMetricsResult -> unionStats windowSize (fromExportMetricsResult exportMetricsResult) old
  ExportTraceResultStat exportTracesResult -> unionStats windowSize (fromExportTraceResult exportTracesResult) old
  ExportProfileResultStat exportProfilesResult -> unionStats windowSize (fromExportProfileResult exportProfilesResult) old

{- |
Internal helper.
Log a statistic.
-}
logStat ::
  Logger IO ->
  Stat ->
  IO ()
logStat logger = \case
  EventCountStat eventCount ->
    -- Log received events.
    when (eventCount.value > 0) $ do
      writeLog logger DEBUG $
        "Received " <> showText eventCount.value <> " events."
  ExportLogsResultStat exportLogsResult -> do
    -- Log exported events.
    when (exportLogsResult.exportedLogRecords > 0) $ do
      writeLog logger DEBUG $
        "Exported " <> showText exportLogsResult.exportedLogRecords <> " logs."
    -- Log rejected events.
    when (exportLogsResult.rejectedLogRecords > 0) $ do
      writeLog logger ERROR $
        "Rejected " <> showText exportLogsResult.rejectedLogRecords <> " logs."
    -- Log exception.
    for_ exportLogsResult.maybeSomeException $ \someException -> do
      writeLog logger ERROR $
        T.pack $
          displayException someException
  ExportMetricsResultStat exportMetricsResult -> do
    -- Log exported events.
    when (exportMetricsResult.exportedDataPoints > 0) $ do
      writeLog logger DEBUG $
        "Exported " <> showText exportMetricsResult.exportedDataPoints <> " metrics."
    -- Log rejected events.
    when (exportMetricsResult.rejectedDataPoints > 0) $ do
      writeLog logger ERROR $
        "Rejected " <> showText exportMetricsResult.rejectedDataPoints <> " metrics."
    -- Log exception.
    for_ exportMetricsResult.maybeSomeException $ \someException -> do
      writeLog logger ERROR $
        T.pack $
          displayException someException
  ExportTraceResultStat exportTracesResult -> do
    -- Log exported events.
    when (exportTracesResult.exportedSpans > 0) $ do
      writeLog logger DEBUG $
        "Exported " <> showText exportTracesResult.exportedSpans <> " spans."
    -- Log rejected events.
    when (exportTracesResult.rejectedSpans > 0) $ do
      writeLog logger ERROR $
        "Rejected " <> showText exportTracesResult.rejectedSpans <> " spans."
    -- Log exception.
    for_ exportTracesResult.maybeSomeException $ \someException -> do
      writeLog logger ERROR $
        T.pack $
          displayException someException
  ExportProfileResultStat exportProfilesResult -> do
    -- Log exported events.
    when (exportProfilesResult.exportedProfiles > 0) $
      writeLog logger DEBUG $
        "Exported " <> showText exportProfilesResult.exportedProfiles <> " profiles."
    -- Log rejected events.
    when (exportProfilesResult.rejectedProfiles > 0) $
      writeLog logger ERROR $
        "Rejected " <> showText exportProfilesResult.rejectedProfiles <> " profiles."
    -- Log exception.
    for_ exportProfilesResult.maybeSomeException $ \someException -> do
      writeLog logger ERROR . T.pack $ displayException someException

{- |
Internal helper.
Display the current stats.
This is intented to be the *only* function printing to the terminal.

TODO: The stats printer should only overwrite the numbers.
-}
displayStats ::
  Logger IO ->
  Double ->
  Stats ->
  IO Stats
displayStats logger eventlogFlushIntervalS stats = do
  -- Check if `displayedLines` is empty...
  case stats.displayedLines of
    First Nothing ->
      -- ...if so, this is the first time this function has been evaluated...
      -- ...so we should perform the `warnIfStderrSupportsANSI` check...
      warnIfStderrSupportsANSI logger
    First (Just numberOfLines) -> do
      -- ...if not, we should clear the previous lines of output...
      liftIO $ ANSI.cursorUp numberOfLines
      liftIO $ ANSI.clearFromCursorToScreenEnd

  -- Compute the moving average count of items _per second_,
  -- by computing the adjusted average of counts over n batches.
  let rate :: Row -> Text
      rate row = T.pack . printf "%0.2f" $ row.ratePerBatch / eventlogFlushIntervalS
  let peak :: Row -> Text
      peak row = T.pack . printf "%0.0f" $ row.peakRatePerBatch

  let cSpec :: [TBL.ColSpec]
      cSpec = [TBL.defColSpec, TBL.defColSpec, TBL.numCol, TBL.numCol, TBL.numCol]
  let hSpec :: TBL.HeaderSpec TBL.LineStyle (Maybe Text)
      hSpec = TBL.titlesH [Just "Item", Just "Action", Just "Total (item)", Just "Rate (item/s)", Just "Peak (item/x)"]
  let mkRow :: Maybe Text -> Maybe Text -> Row -> TBL.RowGroup (Maybe Text)
      mkRow item result row = TBL.rowG [item, result, Just (showText row.total), Just (rate row), Just (peak row)]
  let rSpec :: [TBL.RowGroup (Maybe Text)]
      rSpec =
        [ mkRow (Just "Events") (Just "Received") stats.eventCounts
        , mkRow (Just "Logs") (Just "Exported") stats.exportedLogRecords
        , mkRow Nothing (Just "Rejected") stats.rejectedLogRecords
        , mkRow (Just "Metrics") (Just "Exported") stats.exportedDataPoints
        , mkRow Nothing (Just "Rejected") stats.rejectedDataPoints
        , mkRow (Just "Traces") (Just "Exported") stats.exportedSpans
        , mkRow Nothing (Just "Rejected") stats.rejectedSpans
        , mkRow (Just "Profiles") (Just "Exported") stats.exportedProfiles
        , mkRow Nothing (Just "Rejected") stats.rejectedProfiles
        ]
  let tSpec :: TBL.TableSpec TBL.LineStyle TBL.LineStyle String (Maybe Text) (Maybe Text)
      tSpec = TBL.columnHeaderTableS cSpec TBL.unicodeS hSpec rSpec
  let table = TBL.tableLinesB tSpec :: [Text]
  for_ table $ \row -> liftIO $ TIO.putStrLn row
  pure stats{displayedLines = First (Just $ length table)}

{- |
Check if `IO.stderr` supports ANSI codes. If it does, it is likely printed to
the same terminal as `IO.stdout`, which causes issues if @--stats@ is enabled.
-}
warnIfStderrSupportsANSI ::
  Logger IO ->
  IO ()
warnIfStderrSupportsANSI logger = do
  supportsANSI <- hNowSupportsANSI IO.stderr
  when supportsANSI $ do
    writeLog logger WARN $
      "When statistics are enabled, stderr should be redirected to a file."

-------------------------------------------------------------------------------
-- Internal Helpers
-------------------------------------------------------------------------------

{- |
Internal helper.
Show a value as `Text`.
-}
showText :: (Show a) => a -> Text
showText = T.pack . show