shibuya-metrics-0.10.0.0: src/Shibuya/Metrics/Health.hs
-- | Health check types and logic for Kubernetes-compatible probes.
--
-- Provides:
-- * Liveness probe: Is the system running?
-- * Readiness probe: Is the system ready to handle traffic?
-- * Detailed health status for debugging
module Shibuya.Metrics.Health
( -- * Health Status Types
LivenessStatus (..),
ReadinessStatus (..),
ApplicationStatus (..),
ProcessorHealth (..),
DependencyStatus (..),
-- * Health Check Configuration
HealthConfig (..),
defaultHealthConfig,
-- * Health Check Functions
checkLiveness,
checkReadiness,
checkDetailedHealth,
-- * Dependency Checks
DependencyCheck,
)
where
import Control.Exception
( SomeAsyncException,
SomeException,
displayException,
fromException,
tryJust,
)
import Data.Aeson (ToJSON (..), object, (.=))
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
import Shibuya.App (Master, getAllMetricsIO)
import Shibuya.Core.Metrics
( MetricsMap,
ProcessorId,
ProcessorMetrics (..),
ProcessorState (..),
)
import Shibuya.Internal.Runner.Master
( LifecycleSnapshot,
MasterPhase (..),
ProcessorLifecycle (..),
getLifecycleSnapshotIO,
getMasterPhaseIO,
)
import System.Timeout (timeout)
--------------------------------------------------------------------------------
-- Configuration
--------------------------------------------------------------------------------
-- | Configuration for health checks.
data HealthConfig = HealthConfig
{ -- | Timeout for liveness check (microseconds)
livenessTimeoutMicros :: !Int,
-- | Timeout for each dependency check (microseconds)
dependencyTimeoutMicros :: !Int,
-- | How long a processor can be in Processing state before considered stuck
stuckThreshold :: !NominalDiffTime
}
deriving stock (Eq, Show)
-- | Default health configuration.
-- Liveness timeout: 1 second
-- Stuck threshold: 60 seconds
defaultHealthConfig :: HealthConfig
defaultHealthConfig =
HealthConfig
{ livenessTimeoutMicros = 1_000_000,
dependencyTimeoutMicros = 1_000_000,
stuckThreshold = 60
}
--------------------------------------------------------------------------------
-- Health Status Types
--------------------------------------------------------------------------------
-- | Liveness status for Kubernetes liveness probe.
-- A simple "am I running?" check.
data LivenessStatus = LivenessStatus
{ alive :: !Bool
}
deriving stock (Eq, Show)
instance ToJSON LivenessStatus where
toJSON status =
object
[ "alive" .= status.alive
]
-- | Readiness status for Kubernetes readiness probe.
-- Indicates whether the system is ready to handle traffic.
data ReadinessStatus = ReadinessStatus
{ ready :: !Bool,
application :: !ApplicationStatus,
processors :: !ProcessorHealth,
dependencies :: ![DependencyStatus]
}
deriving stock (Eq, Show)
instance ToJSON ReadinessStatus where
toJSON status =
object
[ "ready" .= status.ready,
"application" .= status.application,
"processors" .= status.processors,
"dependencies" .= status.dependencies
]
-- | Health-level application lifecycle derived from the master phase and the
-- retained configured-processor lifecycle snapshot.
data ApplicationStatus
= ConfiguredEmpty
| Starting
| Running
| Draining
| ApplicationStopped
| ApplicationFailed
deriving stock (Eq, Show)
instance ToJSON ApplicationStatus where
toJSON = \case
ConfiguredEmpty -> toJSON ("configured_empty" :: Text)
Starting -> toJSON ("starting" :: Text)
Running -> toJSON ("running" :: Text)
Draining -> toJSON ("draining" :: Text)
ApplicationStopped -> toJSON ("stopped" :: Text)
ApplicationFailed -> toJSON ("failed" :: Text)
-- | Summary of processor health across all processors.
data ProcessorHealth = ProcessorHealth
{ total :: !Int,
healthy :: !Int,
failed :: !Int,
stuck :: !Int
}
deriving stock (Eq, Show)
instance ToJSON ProcessorHealth where
toJSON ph =
object
[ "total" .= ph.total,
"healthy" .= ph.healthy,
"failed" .= ph.failed,
"stuck" .= ph.stuck
]
-- | Status of an 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,
"latencyMs" .= ds.latencyMs,
"error" .= ds.errorMsg
]
-- | A dependency check is an IO action that returns the dependency's status.
-- Synchronous exceptions become an unhealthy status; asynchronous exceptions
-- remain cancellation signals and are rethrown.
type DependencyCheck = IO DependencyStatus
--------------------------------------------------------------------------------
-- Health Check Functions
--------------------------------------------------------------------------------
-- | Check liveness - is the master responding?
-- This is a fast check suitable for Kubernetes liveness probes.
checkLiveness :: HealthConfig -> Master -> IO LivenessStatus
checkLiveness config master = do
result <- timeout config.livenessTimeoutMicros $ getMasterPhaseIO master
pure $
LivenessStatus
{ alive = case result of
Just MasterStopped -> False
Just _ -> True
Nothing -> False
}
-- | Check readiness - are all processors healthy and dependencies available?
-- This is suitable for Kubernetes readiness probes.
checkReadiness ::
HealthConfig ->
Master ->
[DependencyCheck] ->
IO ReadinessStatus
checkReadiness config master depChecks = do
now <- getCurrentTime
metrics <- getAllMetricsIO master
lifecycles <- getLifecycleSnapshotIO master
masterPhase <- getMasterPhaseIO master
let procHealth = analyzeProcessorHealth config now metrics lifecycles
application = classifyApplication masterPhase lifecycles
allRunningVisible =
all
(\(pid, lifecycle) -> lifecycle /= LifecycleRunning || Map.member pid metrics)
(Map.toList lifecycles)
depStatus <- traverse (runDependencyCheck config) depChecks
let allDepsHealthy = all (.healthy) depStatus
noFailedProcessors = procHealth.failed == 0
noStuckProcessors = procHealth.stuck == 0
acceptsWork = application == Running || application == ConfiguredEmpty
isReady = acceptsWork && allRunningVisible && allDepsHealthy && noFailedProcessors && noStuckProcessors
pure
ReadinessStatus
{ ready = isReady,
application,
processors = procHealth,
dependencies = depStatus
}
-- | Get detailed health status for debugging.
-- Returns the full metrics along with health analysis.
checkDetailedHealth ::
HealthConfig ->
Master ->
[DependencyCheck] ->
IO (ReadinessStatus, MetricsMap)
checkDetailedHealth config master depChecks = do
readiness <- checkReadiness config master depChecks
metrics <- getAllMetricsIO master
pure (readiness, metrics)
--------------------------------------------------------------------------------
-- Internal Helpers
--------------------------------------------------------------------------------
-- | Analyze processor health from metrics.
analyzeProcessorHealth :: HealthConfig -> UTCTime -> MetricsMap -> LifecycleSnapshot -> ProcessorHealth
analyzeProcessorHealth config now metrics lifecycles =
let processorIds = Map.keysSet metrics <> Map.keysSet lifecycles
total = length processorIds
(healthy, failed, stuck) =
foldr
(categorize config now metrics lifecycles)
(0, 0, 0)
processorIds
in ProcessorHealth
{ total = total,
healthy = healthy,
failed = failed,
stuck = stuck
}
-- | Categorize a processor as healthy, failed, or stuck.
categorize ::
HealthConfig ->
UTCTime ->
MetricsMap ->
LifecycleSnapshot ->
ProcessorId ->
(Int, Int, Int) ->
(Int, Int, Int)
categorize config now metrics lifecycles pid counts@(h, f, s) =
case Map.lookup pid lifecycles of
Just LifecycleFailed {} -> (h, f + 1, s)
_ -> case Map.lookup pid metrics of
Nothing -> counts
Just pm -> case pm.state of
Idle -> (h + 1, f, s)
Stopped -> counts
Failed _ _ -> (h, f + 1, s)
Processing _ _ lastProgress ->
let timeSinceProgress = diffUTCTime now lastProgress
in if timeSinceProgress > config.stuckThreshold
then (h, f, s + 1)
else (h + 1, f, s)
classifyApplication :: MasterPhase -> LifecycleSnapshot -> ApplicationStatus
classifyApplication masterPhase lifecycles
| any isFailed (Map.elems lifecycles) = ApplicationFailed
| masterPhase == MasterStopped = ApplicationStopped
| masterPhase == MasterDraining || any (== LifecycleDraining) (Map.elems lifecycles) = Draining
| masterPhase == MasterStarting = Starting
| Map.null lifecycles = ConfiguredEmpty
| all (== LifecycleStopped) (Map.elems lifecycles) = ApplicationStopped
| otherwise = Running
where
isFailed LifecycleFailed {} = True
isFailed _ = False
runDependencyCheck :: HealthConfig -> DependencyCheck -> IO DependencyStatus
runDependencyCheck config check = do
result <- timeout config.dependencyTimeoutMicros $ tryJust synchronousException check
pure $ case result of
Just (Right status) -> status
Just (Left err) ->
DependencyStatus
{ name = "unknown",
healthy = False,
latencyMs = Nothing,
errorMsg = Just $ Text.pack $ displayException err
}
Nothing ->
DependencyStatus
{ name = "unknown",
healthy = False,
latencyMs = Nothing,
errorMsg =
Just $
"Dependency check timed out after "
<> Text.pack (show config.dependencyTimeoutMicros)
<> " microseconds"
}
synchronousException :: SomeException -> Maybe SomeException
synchronousException exception =
case fromException exception :: Maybe SomeAsyncException of
Just _ -> Nothing
Nothing -> Just exception