kiroku-metrics-0.1.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)))