packages feed

valiant-0.1.0.0: src/Valiant/Logging.hs

-- | Logging hooks for query timing and connection events.
module Valiant.Logging
  ( LogEvent (..)
  , LogLevel (..)
  , Logger
  , nullLogger
  , stderrLogger
  , withQueryLogging
  , poolLoggerFromLogger
  ) where

import Control.Exception (SomeException, displayException, throwIO, try)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.Time (NominalDiffTime, diffUTCTime, getCurrentTime)
import PgWire.Pool.Config (PoolLogger)
import System.IO (hPutStrLn, stderr)

-- | Log severity levels.
data LogLevel = Debug | Info | Warn | Error
  deriving stock (Show, Eq, Ord)

-- | Events that can be logged.
data LogEvent
  = QueryStart ByteString
  | QueryComplete ByteString NominalDiffTime Int
  -- ^ SQL, duration, rows returned
  | QueryError ByteString NominalDiffTime ByteString
  -- ^ SQL, duration, error message
  | ConnectionOpened ByteString
  -- ^ Connection string (masked)
  | ConnectionClosed
  | PoolAcquire NominalDiffTime
  -- ^ Time waited for a connection
  | PoolRelease
  | PoolTimeout
  | PoolCreatedConn
  -- ^ A new connection was created in the pool
  | PoolDestroyedConn ByteString
  -- ^ A connection was destroyed. Includes reason.
  | PoolRecycled
  -- ^ A connection was recycled (health check passed)
  | PoolReaperSwept Int
  -- ^ Reaper swept N idle/expired connections
  | PoolWarmerCreated Int
  -- ^ Warmer created N connections to maintain min-idle
  | PoolResized Int Int
  -- ^ Pool resized from old size to new size
  deriving stock (Show)

-- | A logging callback.
type Logger = LogLevel -> LogEvent -> IO ()

-- | Logger that discards all events.
nullLogger :: Logger
nullLogger _ _ = pure ()

-- | Simple logger that writes to stderr.
stderrLogger :: LogLevel -> Logger
stderrLogger minLevel level event
  | level >= minLevel = hPutStrLn stderr (formatEvent level event)
  | otherwise = pure ()

-- | Bracket a query with timing and logging. Emits 'QueryStart',
-- then either 'QueryComplete' on success or 'QueryError' on
-- exception. Exceptions are re-raised after logging.
withQueryLogging :: Logger -> ByteString -> IO a -> IO a
withQueryLogging logger sql action = do
  logger Debug (QueryStart sql)
  start <- getCurrentTime
  outcome <- try action
  end <- getCurrentTime
  let elapsed = diffUTCTime end start
  case outcome of
    Right result -> do
      logger Debug (QueryComplete sql elapsed 0)
      pure result
    Left (e :: SomeException) -> do
      logger Warn (QueryError sql elapsed (BS8.pack (displayException e)))
      throwIO e

-- | Create a 'PoolLogger' that forwards pool events to a 'Logger' as
-- structured 'LogEvent's. Pass this as the @poolLogger@ field when
-- constructing a 'PgWire.Pool.Config.PoolConfig'.
--
-- @
-- let cfg = defaultPoolConfig
--       { poolLogger = poolLoggerFromLogger (stderrLogger Info)
--       }
-- @
poolLoggerFromLogger :: Logger -> PoolLogger
poolLoggerFromLogger logger level msg = logger valiantLevel event
  where
    valiantLevel = case level of
      "debug" -> Debug
      "info" -> Info
      "warn" -> Warn
      _ -> Info
    event = parsePoolEvent msg

-- | Parse the pool's log message into a structured 'LogEvent'.
-- Falls back to a generic 'PoolDestroyedConn' with the raw message
-- if the pattern doesn't match a known event.
parsePoolEvent :: ByteString -> LogEvent
parsePoolEvent msg
  | msg == "created connection" = PoolCreatedConn
  | msg == "recycled connection" = PoolRecycled
  | msg == "acquire timeout" = PoolTimeout
  | "destroyed connection: " `BS8.isPrefixOf` msg =
      PoolDestroyedConn (BS8.drop 22 msg)
  | "reaper swept " `BS8.isPrefixOf` msg =
      case BS8.readInt (BS8.drop 14 msg) of
        Just (n, _) -> PoolReaperSwept n
        Nothing -> PoolDestroyedConn msg
  | "warmer created " `BS8.isPrefixOf` msg =
      case BS8.readInt (BS8.drop 15 msg) of
        Just (n, _) -> PoolWarmerCreated n
        Nothing -> PoolDestroyedConn msg
  | "resized from " `BS8.isPrefixOf` msg =
      case parseResized (BS8.drop 13 msg) of
        Just (old, new') -> PoolResized old new'
        Nothing -> PoolDestroyedConn msg
  | otherwise = PoolDestroyedConn msg
  where
    parseResized bs = do
      (old, rest) <- BS8.readInt bs
      -- rest should be " to N"
      let rest' = BS8.drop 4 rest -- drop " to "
      (new', _) <- BS8.readInt rest'
      pure (old, new')

formatEvent :: LogLevel -> LogEvent -> String
formatEvent level event =
  "[valiant:" <> show level <> "] " <> case event of
    QueryStart sql -> "query start: " <> trunc 80 (BS8.unpack sql)
    QueryComplete sql dur rows ->
      "query complete: " <> trunc 60 (BS8.unpack sql)
        <> " (" <> show dur <> ", " <> show rows <> " rows)"
    QueryError sql dur msg ->
      "query error: " <> trunc 60 (BS8.unpack sql)
        <> " (" <> show dur <> "): " <> BS8.unpack msg
    ConnectionOpened cs -> "connection opened: " <> BS8.unpack cs
    ConnectionClosed -> "connection closed"
    PoolAcquire dur -> "pool acquire (" <> show dur <> ")"
    PoolRelease -> "pool release"
    PoolTimeout -> "pool timeout"
    PoolCreatedConn -> "pool created connection"
    PoolDestroyedConn reason -> "pool destroyed connection: " <> BS8.unpack reason
    PoolRecycled -> "pool recycled connection"
    PoolReaperSwept n -> "pool reaper swept " <> show n <> " connections"
    PoolWarmerCreated n -> "pool warmer created " <> show n <> " connections"
    PoolResized old new' -> "pool resized from " <> show old <> " to " <> show new'
  where
    trunc n s
      | length s <= n = s
      | otherwise = take n s <> "..."