packages feed

consumers-metrics-prometheus-1.0.0.0: src/Database/PostgreSQL/Consumers/Instrumented.hs

-- |
-- Provides seamless instrumentation of your existing @consumers@ using Prometheus
-- (see the [`consumers`](https://hackage.haskell.org/package/consumers) library
-- for usage).
module Database.PostgreSQL.Consumers.Instrumented
  ( -- * Instrument
    runInstrumentedConsumer

    -- ** Configuration
  , defaultConsumerMetricsConfig
  , ConsumerMetricsConfig (..)

    -- ** Metrics
  , ConsumerMetrics
  , registerConsumerMetrics
  ) where

import Control.Concurrent.Lifted
import Control.Exception.Safe
import Control.Monad.Base
import Control.Monad.Catch (ExitCase (..))
import Control.Monad.Time
import Control.Monad.Trans.Control
import Database.PostgreSQL.Consumers (ConsumerConfig (..), Result (..), runConsumer)
import Database.PostgreSQL.PQTypes
import Log
import Prometheus qualified as Prom

data ConsumerMetricsConfig = ConsumerMetricsConfig
  { collectSeconds :: Int
  -- ^ Collection interval in seconds
  , jobExecutionBuckets :: [Prom.Bucket]
  -- ^ Buckets to use for the 'jobExecution' histogram
  , collectDegradeThresholdSeconds :: Double
  -- ^ @logAttention@ and graceful degrade if collection takes longer than @x@ seconds
  , collectDegradeSeconds :: Int
  -- ^ Degraded collection interval in seconds
  }

-- | Hopefully sensible defaults that you can use
--
-- @
--  ConsumerMetricsConfig
--    { collectSeconds = 15
--    , jobExecutionBuckets = [0.01, 0.05, 0.1, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
--    , collectDegradeThresholdSeconds = 0.1
--    , collectDegradeSeconds = 60
--    }
-- @
defaultConsumerMetricsConfig :: ConsumerMetricsConfig
defaultConsumerMetricsConfig =
  ConsumerMetricsConfig
    { collectSeconds = 15
    , jobExecutionBuckets = [0.01, 0.05, 0.1, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
    , collectDegradeThresholdSeconds = 0.1
    , collectDegradeSeconds = 60
    }

-- | Metrics store for the following Prometheus metrics:
--
-- @
-- # HELP consumers_job_execution_seconds Execution time of jobs in seconds, by job_name, includes the job_result
-- # TYPE consumers_job_execution_seconds histogram
--
-- # HELP consumers_jobs_reserved_total The total number of job reserved, by job_name
-- # TYPE consumers_jobs_reserved_total counter
--
-- # HELP consumers_jobs_overdue The current number of jobs overdue, by job_name
-- # TYPE consumers_jobs_overdue gauge
--
-- # HELP consumers_job_info The number of workers registered for a given job_name
-- # TYPE consumers_job_info gauge
-- @
data ConsumerMetrics = ConsumerMetrics
  { collectSeconds :: Int
  , collectDegradeThresholdSeconds :: Double
  , collectDegradeSeconds :: Int
  , jobInfo :: Prom.Vector Prom.Label1 Prom.Gauge
  , jobsOverdue :: Prom.Vector Prom.Label1 Prom.Gauge
  , jobsReserved :: Prom.Vector Prom.Label1 Prom.Counter
  , jobsExecution :: Prom.Vector Prom.Label2 Prom.Histogram
  }

registerConsumerMetrics :: MonadBaseControl IO m => ConsumerMetricsConfig -> m ConsumerMetrics
registerConsumerMetrics ConsumerMetricsConfig {..} = liftBase $ do
  jobInfo <-
    Prom.register
      . Prom.vector "job_name"
      $ Prom.gauge
        Prom.Info
          { metricName = "consumers_job_info"
          , metricHelp = "The number of workers registered for a given job_name"
          }
  jobsOverdue <-
    Prom.register
      . Prom.vector "job_name"
      $ Prom.gauge
        Prom.Info
          { metricName = "consumers_jobs_overdue"
          , metricHelp = "The current number of jobs overdue, by job_name"
          }
  jobsReserved <-
    Prom.register
      . Prom.vector "job_name"
      $ Prom.counter
        Prom.Info
          { metricName = "consumers_jobs_reserved_total"
          , metricHelp = "The total number of job reserved, by job_name"
          }
  jobsExecution <-
    Prom.register
      . Prom.vector ("job_name", "job_result")
      $ Prom.histogram
        Prom.Info
          { metricName = "consumers_job_execution_seconds"
          , metricHelp = "Execution time of jobs in seconds, by job_name, includes the job_result"
          }
        jobExecutionBuckets
  pure $ ConsumerMetrics {..}

-- | Run a 'ConsumerConfig', but with instrumentation added.
--
-- This should be used in place of 'runConsumer'.
-- Use 'registerConsumerMetrics' to create the metrics.
--
-- A thread will spawned to collect "queue" metrics every 'collectSeconds', and
-- an altered @ccProcessJob@ will be run that collects "job" metrics.
--
-- See 'ConsumerMetrics' for more details on the metrics collected.
runInstrumentedConsumer
  :: forall m idx job
   . ( Eq idx
     , Show idx
     , FromSQL idx
     , ToSQL idx
     , MonadBaseControl IO m
     , MonadMask m
     , MonadTime m
     , MonadLog m
     )
  => ConsumerMetrics
  -> ConsumerConfig m idx job
  -> ConnectionSourceM m
  -> m (m ())
runInstrumentedConsumer metrics config connSource = do
  let instrumentedConfig = instrumentConsumerConfig metrics config
  cleanupConsumer <- runConsumer instrumentedConfig connSource
  tidMetricsCollection <- runMetricsCollection connSource metrics config
  pure $ do
    cleanupConsumer
    killThread tidMetricsCollection

-- | Spawns a new thread to run "queue" metrics collection for the given configuration
runMetricsCollection
  :: forall m idx job
   . ( MonadBaseControl IO m
     , MonadMask m
     , MonadTime m
     , MonadLog m
     )
  => ConnectionSourceM m
  -> ConsumerMetrics
  -> ConsumerConfig m idx job
  -> m ThreadId
runMetricsCollection connSource metrics@ConsumerMetrics {..} config = localDomain "metrics-collection" $ fork collectLoop
  where
    collectLoop = do
      seconds <- handleAny handleEx collect
      threadDelay (seconds * 1_000_000)
      collectLoop

    handleEx e = do
      logAttention "Exception while running metrics-collection" $
        object
          [ "exception" .= show e
          , "rerun_seconds" .= collectDegradeSeconds
          ]
      pure collectDegradeSeconds

    collect = do
      logInfo_ "Collecting consumer metrics"
      t1 <- monotonicTime
      collectMetrics connSource metrics config
      t2 <- monotonicTime
      let runtime = t2 - t1
      -- Graceful degrade if things take too long
      if runtime < collectDegradeThresholdSeconds
        then pure collectSeconds
        else do
          logAttention "Consumer metrics collection took long" $
            object
              [ "runtime" .= runtime
              , "threshold" .= collectDegradeThresholdSeconds
              , "rerun_seconds" .= collectDegradeSeconds
              ]
          pure collectDegradeSeconds

-- | Collect and report "queue" metrics for a given configuration
collectMetrics
  :: ( MonadBaseControl IO m
     , MonadMask m
     , MonadThrow m
     )
  => ConnectionSourceM m
  -> ConsumerMetrics
  -> ConsumerConfig m idx job
  -> m ()
collectMetrics connSource ConsumerMetrics {..} ConsumerConfig {ccJobsTable, ccConsumersTable} = runDBT connSource defaultTransactionSettings $ do
  let jobName = unRawSQL ccJobsTable

  info <- do
    runSQL_ $
      "SELECT count(id)::float8 FROM "
        <> raw ccConsumersTable
        <> " WHERE name =" <?> unRawSQL ccJobsTable
    fetchOne runIdentity
  liftBase $ Prom.withLabel jobInfo jobName (`Prom.setGauge` info)

  overdue <- do
    runSQL_ $
      "SELECT count(id)::float8 FROM "
        <> raw ccJobsTable
        <> " WHERE run_at <= now() AND reserved_by IS NULL"
    fetchOne runIdentity
  liftBase $ Prom.withLabel jobsOverdue jobName (`Prom.setGauge` overdue)

-- | Alter a configuration to collect "job" metrics on 'ccProcessJob'
instrumentConsumerConfig
  :: ( MonadBaseControl IO m
     , MonadMask m
     , MonadTime m
     , MonadLog m
     )
  => ConsumerMetrics
  -> ConsumerConfig m idx job
  -> ConsumerConfig m idx job
instrumentConsumerConfig ConsumerMetrics {..} ConsumerConfig {..} =
  ConsumerConfig {ccProcessJob = ccProcessJob', ..}
  where
    jobName = unRawSQL ccJobsTable

    -- Alter the `ccProcessJob` function by adding instrumentation:
    -- First we increment the 'metrics.jobsReserved' counter (while handling
    -- any potential exceptions).  Then we use `generalBracket` to acquire the
    -- start time, process the job, and finally report the job metrics as the
    -- "release" action.  Any exceptions in `ccProcessJob` will be re-raised by
    -- `generalBracket`.  However, we need to handle exceptions in the
    -- "release" action as we don't want those exceptions to propagate upwards
    -- to the consumer's `ccOnException` (and thus potentially change the
    -- result of the job).
    ccProcessJob' job = do
      handleAny handleEx . liftBase $ Prom.withLabel jobsReserved jobName Prom.incCounter
      fst <$> generalBracket monotonicTime reportJob (const $ ccProcessJob job)

    reportJob t1 jobExit = handleAny handleEx $ do
      t2 <- monotonicTime
      let duration = t2 - t1
          resultLabel = case jobExit of
            ExitCaseSuccess (Ok _) -> "ok"
            ExitCaseSuccess (Failed _) -> "failed"
            ExitCaseException _ -> "exception"
            ExitCaseAbort -> "abort"
      liftBase $ Prom.withLabel jobsExecution (jobName, resultLabel) (`Prom.observe` duration)

    handleEx e = logAttention "Exception while instrumenting job" $ object ["exception" .= show e]