packages feed

eventlog-live-0.5.0.0: src/GHC/Eventlog/Live/Logger.hs

{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : GHC.Eventlog.Live..Logger
Description : Logging functions.
Stability   : experimental
Portability : portable
-}
module GHC.Eventlog.Live.Logger (
  Logger,
  MyTelemetryData (..),
  writeLog,
  writeException,
  writeMetric,
  filterBySeverity,
  stderrLogger,
  handleLogger,
  chanLogger,
  chanSource,
) where

import Colog.Core.Action (cfilter, (<&))
import Colog.Core.Action qualified as CCA (LogAction (..))
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TChan (TChan, readTChan, writeTChan)
import Control.Exception (Exception (..), bracket_)
import Control.Monad ((<=<))
import Control.Monad.IO.Class (MonadIO (..))
import Data.Ix (Ix (..))
import Data.Machine (SourceT, repeatedly, yield)
import Data.Maybe (isNothing)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Builder qualified as TLB
import GHC.Eventlog.Live.Data.Attribute (AttrValue (..), (~=))
import GHC.Eventlog.Live.Data.Attribute qualified as A
import GHC.Eventlog.Live.Data.LogRecord (LogRecord (..))
import GHC.Eventlog.Live.Data.Metric (KnownMetricType, Metric (..), SomeMetric (..))
import GHC.Eventlog.Live.Data.Severity (Severity (..), toSeverityString)
import GHC.RTS.Events (Timestamp)
import GHC.Stack (callStack, prettyCallStack, withFrozenCallStack)
import GHC.Stack.Types (HasCallStack)
import System.Clock (Clock (..), TimeSpec (..), getTime)
import System.Console.ANSI (Color (..), ColorIntensity (..), ConsoleLayer (..), SGR (..), hNowSupportsANSI, hSetSGR)
import System.IO qualified as IO
import Prelude hiding (log)

type Logger m = CCA.LogAction m MyTelemetryData

{- |
The type of internal telemetry data.
-}
data MyTelemetryData
  = MyTelemetryData'LogRecord {logRecord :: !LogRecord}
  | MyTelemetryData'Metric {metric :: !SomeMetric}

{- |
Use a `Logger` to log a message with a severity.
-}
writeLog :: (HasCallStack) => Logger m -> Severity -> Text -> m ()
writeLog logger severity body =
  withFrozenCallStack $
    logger
      <& MyTelemetryData'LogRecord
        { logRecord =
            LogRecord
              { body
              , maybeSeverity = Just severity
              , maybeTimeUnixNano = Nothing
              , attrs = ["call-stack" ~= prettyCallStack callStack]
              }
        }

{- |
Use a `Logger` to log an exception.
-}
writeException :: (Exception e) => Logger m -> e -> m ()
writeException logger e =
  writeLog logger ERROR (T.pack $ displayException e)

{- |
Use a `Logger` to log an internal metric.
-}
writeMetric ::
  forall m metricType.
  (KnownMetricType metricType) =>
  Logger m ->
  -- | The metric name.
  String ->
  metricType ->
  m ()
writeMetric logger metricName value =
  logger
    <& MyTelemetryData'Metric
      SomeMetric
        { metricName
        , metric =
            Metric
              { value
              , maybeTimeUnixNano = Nothing
              , maybeStartTimeUnixNano = Nothing
              , attrs = []
              }
        }

{- |
A `Logger` that writes each `LogRecord` to a `IO.stderr` and ignores all other telemetry data.

__TODO:__ Support the remaining telemetry data.
-}
stderrLogger :: Logger IO
stderrLogger = handleLogger IO.stderr

{- |
A `Logger` that writes each `LogRecord` to a `IO.Handle` and ignores all other telemetry data.

__TODO:__ Support the remaining telemetry data.
-}
handleLogger ::
  IO.Handle ->
  Logger IO
handleLogger handle = CCA.LogAction $ \case
  MyTelemetryData'LogRecord logRecord -> liftIO $ do
    withSeverityColor logRecord.maybeSeverity handle $ \handleWithColor ->
      TIO.hPutStrLn handleWithColor $ formatLogRecord logRecord
    IO.hFlush handle
  MyTelemetryData'Metric{} -> pure ()

{- |
Filter a @`Logger` m@ by a `Severity`.
-}
filterBySeverity ::
  (Applicative m) =>
  Severity ->
  Logger m ->
  Logger m
filterBySeverity severityThreshold =
  cfilter severityFilter
 where
  severityFilter = \case
    MyTelemetryData'LogRecord{..} ->
      maybe False (>= severityThreshold) logRecord.maybeSeverity
    _otherwise -> True

{- |
Internal helper.
Format the message appropriately for the given verbosity level and threshold.
-}
formatLogRecord :: LogRecord -> Text
formatLogRecord logRecord =
  TL.toStrict . TLB.toLazyText . mconcat $
    [ -- format the severity
      maybe "" (\severity -> "[" <> TLB.fromString (toSeverityString severity) <> "] ") logRecord.maybeSeverity
    , -- format the body
      TLB.fromText logRecord.body
    , -- format the call-stack, if any
      case A.lookup "call-stack" logRecord.attrs of
        Just (AttrText theCallStack)
          | maybe False (>= ERROR) logRecord.maybeSeverity ->
              "\n" <> TLB.fromText theCallStack
        _otherwise -> ""
    ]

{- |
Internal helper.
Determine the ANSI color and intensity associated with a particular `Severity`.
-}
severityColor :: Severity -> Maybe (Color, ColorIntensity)
severityColor severity
  | inRange (TRACE, TRACE4) severity = Just (Blue, Dull)
  | inRange (DEBUG, DEBUG4) severity = Just (Blue, Vivid)
  | inRange (WARN, WARN4) severity = Just (Yellow, Vivid)
  | inRange (ERROR, ERROR4) severity = Just (Red, Dull)
  | inRange (FATAL, FATAL4) severity = Just (Red, Vivid)
  | otherwise = Nothing

{- |
Internal helper.
Use a handle with the color set appropriately for the given `Severity`.
-}
withSeverityColor :: Maybe Severity -> IO.Handle -> (IO.Handle -> IO a) -> IO a
withSeverityColor maybeSeverity handle action = do
  supportsANSI <- hNowSupportsANSI handle
  if not supportsANSI
    then
      action handle
    else case severityColor =<< maybeSeverity of
      Nothing ->
        action handle
      Just (color, intensity) -> do
        let setVerbosityColor = hSetSGR handle [SetColor Foreground intensity color]
        let setDefaultColor = hSetSGR handle [SetDefaultColor Foreground]
        bracket_ setVerbosityColor setDefaultColor $ action handle

{- |
A `Logger` that writes the internal telemetry data to a channel.
-}
chanLogger :: TChan MyTelemetryData -> Logger IO
chanLogger chan =
  CCA.LogAction $
    atomically . writeTChan chan <=< addTimeUnixNano

{- |
A `Souce` that reads the data from a channel.
-}
chanSource :: (MonadIO m) => TChan a -> SourceT m a
chanSource chan = repeatedly $ do
  a <- liftIO $ atomically $ readTChan chan
  yield a

{- |
Add the current Unix timestamp in nanoseconds to telemetry data.
-}
addTimeUnixNano :: MyTelemetryData -> IO MyTelemetryData
addTimeUnixNano myTelemetryData =
  case myTelemetryData of
    MyTelemetryData'LogRecord{logRecord = LogRecord{..}}
      | isNothing maybeTimeUnixNano -> do
          timeUnixNano <- getTimeUnixNano
          pure $
            MyTelemetryData'LogRecord
              LogRecord{maybeTimeUnixNano = Just timeUnixNano, ..}
      | otherwise -> pure myTelemetryData
    MyTelemetryData'Metric{metric = SomeMetric{metricName, metric = Metric{..}}}
      | isNothing maybeTimeUnixNano -> do
          timeUnixNano <- getTimeUnixNano
          pure $
            MyTelemetryData'Metric
              SomeMetric
                { metricName
                , metric = Metric{maybeTimeUnixNano = Just timeUnixNano, ..}
                }
      | otherwise -> pure myTelemetryData

{- |
Get the current Unix time in nanoseconds.

__Warning:__ This will start overflowing in the year 2554.
-}
getTimeUnixNano :: IO Timestamp
getTimeUnixNano = toNanos <$> getTime Realtime
 where
  -- NOTE: This will overflow if @t.sec > (2^64 - 1) `div` 10^9@,
  --       which means you're running this code in the year 2554.
  --       What's that like?
  toNanos :: TimeSpec -> Timestamp
  toNanos t = 1_000_000_000 * fromIntegral t.sec + fromIntegral t.nsec