packages feed

kiroku-metrics-0.4.0.0: src/Kiroku/Metrics/Health.hs

-- | Kubernetes-style health checks derived from the metrics snapshot.
--
-- * Liveness — can we read a snapshot within a time budget? (Is the process and
--   collector responsive?)
-- * Readiness — is the store ready to serve: no subscription overflow-stopped, no
--   subscription lagging beyond 'Kiroku.Metrics.Config.readinessMaxLag', and all
--   configured 'DependencyCheck's passing?
-- * Detailed health — the readiness verdict plus the full snapshot, for humans.
--
-- The built-in 'postgresPing' dependency check issues @SELECT 1@ through the
-- store's pool. Other dependency checks are arbitrary @IO 'DependencyStatus'@
-- actions the caller supplies.
--
-- The lag signal is an /upper bound/: the collector observes a subscription's
-- position only at lifecycle callback points (see the collector's limitation), so
-- readiness may briefly report a caught-up subscription as lagging until its next
-- lifecycle event.
module Kiroku.Metrics.Health
  ( DependencyCheck,
    DependencyStatus (..),
    LivenessStatus (..),
    ReadinessStatus (..),
    checkLiveness,
    checkReadiness,
    checkDetailedHealth,
    postgresPing,
  )
where

import Data.Aeson (ToJSON (..), object, (.=))
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Clock (getMonotonicTimeNSec)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import System.Timeout (timeout)

import Kiroku.Metrics.Collector (KirokuMetrics, snapshotMetrics)
import Kiroku.Metrics.Config (MetricsServerConfig (..))
import Kiroku.Metrics.Types
  ( MetricsSnapshot (..),
    SubscriptionMetrics (..),
  )
import Kiroku.Store (KirokuStore (..))

-- | A dependency check is an IO action returning that dependency's status.
type DependencyCheck = IO DependencyStatus

-- | Status of one external dependency.
data DependencyStatus = DependencyStatus
  { name :: !Text,
    healthy :: !Bool,
    latencyMs :: !(Maybe Int),
    errorMsg :: !(Maybe Text)
  }
  deriving stock (Eq, Show)

instance ToJSON DependencyStatus where
  toJSON ds =
    object
      [ "name" .= ds.name,
        "healthy" .= ds.healthy,
        "latency_ms" .= ds.latencyMs,
        "error" .= ds.errorMsg
      ]

-- | Liveness verdict.
newtype LivenessStatus = LivenessStatus {alive :: Bool}
  deriving stock (Eq, Show)

instance ToJSON LivenessStatus where
  toJSON s = object ["alive" .= s.alive]

-- | Readiness verdict and its components.
data ReadinessStatus = ReadinessStatus
  { ready :: !Bool,
    lagOk :: !Bool,
    noOverflow :: !Bool,
    dependencies :: ![DependencyStatus]
  }
  deriving stock (Eq, Show)

instance ToJSON ReadinessStatus where
  toJSON s =
    object
      [ "ready" .= s.ready,
        "lag_ok" .= s.lagOk,
        "no_overflow" .= s.noOverflow,
        "dependencies" .= s.dependencies
      ]

-- | Liveness: can we take a snapshot within 'livenessTimeoutUs'? Proves the
-- collector and store @TVar@s are reachable within the budget.
checkLiveness :: MetricsServerConfig -> KirokuMetrics -> IO LivenessStatus
checkLiveness cfg m = do
  result <- timeout cfg.livenessTimeoutUs (snapshotMetrics m)
  pure (LivenessStatus {alive = isJust result})

-- | Readiness: no subscription overflow-stopped, none lagging beyond
-- 'readinessMaxLag', and all dependency checks healthy.
checkReadiness :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> IO ReadinessStatus
checkReadiness cfg m depChecks = do
  snap <- snapshotMetrics m
  deps <- sequence depChecks
  let subs = Map.elems snap.subscriptions
      noOverflow = not (any ((== Just "overflow") . (.lastStopReason)) subs)
      lagOk = all ((<= cfg.readinessMaxLag) . (.lag)) subs
      depsOk = all (.healthy) deps
  pure
    ReadinessStatus
      { ready = noOverflow && lagOk && depsOk,
        lagOk,
        noOverflow,
        dependencies = deps
      }

-- | Detailed health: the readiness verdict plus the full snapshot.
checkDetailedHealth ::
  MetricsServerConfig ->
  KirokuMetrics ->
  [DependencyCheck] ->
  IO (ReadinessStatus, MetricsSnapshot)
checkDetailedHealth cfg m depChecks = do
  readiness <- checkReadiness cfg m depChecks
  snap <- snapshotMetrics m
  pure (readiness, snap)

-- | Built-in dependency check: time a @SELECT 1@ through the store's pool. A
-- @Right@ result is healthy; a @Left@ 'Pool.UsageError' is unhealthy with the
-- error rendered into 'errorMsg'.
postgresPing :: KirokuStore -> DependencyCheck
postgresPing store = do
  t0 <- getMonotonicTimeNSec
  result <- Pool.use store.pool (Session.script "SELECT 1")
  t1 <- getMonotonicTimeNSec
  let ms = fromIntegral ((t1 - t0) `div` 1_000_000)
  pure $ case result of
    Right () -> DependencyStatus "postgres" True (Just ms) Nothing
    Left e -> DependencyStatus "postgres" False (Just ms) (Just (T.pack (show e)))