shibuya-core-0.8.0.0: src/Shibuya/Core/Metrics.hs
-- | Metrics and state tracking for processors.
-- Provides introspection into what's happening in the system.
module Shibuya.Core.Metrics
( -- * Processor State
ProcessorState (..),
ProcessorId (..),
-- * In-Flight Tracking
InFlightInfo (..),
emptyInFlightInfo,
-- * Stream Statistics
StreamStats (..),
emptyStreamStats,
-- * Batch Statistics
BatchStats (..),
emptyBatchStats,
incBatchesEmitted,
addBatchedMessages,
incPartialFailures,
incSizeTriggered,
incTimeoutTriggered,
incFlushTriggered,
-- * Combined Metrics
ProcessorMetrics (..),
emptyProcessorMetrics,
HotCounters (..),
MetricsHandle (..),
newMetricsHandle,
sampleMetrics,
incrementReceived,
beginProcessing,
AckDecisionMetric (..),
finishProcessing,
finishFinalizationFailure,
BatchTriggerMetric (..),
recordBatchOutcomeMetrics,
-- * Metrics Map
MetricsMap,
-- * Metrics Updates
incReceived,
incProcessed,
incFailed,
)
where
import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO)
import Control.Monad (unless, void, when)
import Data.Aeson (FromJSON (..), FromJSONKey (..), ToJSON (..), ToJSONKey (..), object, withObject, (.:))
import Data.Aeson qualified as Aeson
import Data.Atomics.Counter (AtomicCounter, incrCounter, readCounter)
import Data.Atomics.Counter qualified as Counter
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Map.Strict (Map)
import Data.Text qualified as Text
import Shibuya.Prelude
-- | Processor identifier.
newtype ProcessorId = ProcessorId {unProcessorId :: Text}
deriving stock (Eq, Ord, Show, Generic)
deriving newtype (ToJSON, FromJSON, ToJSONKey, FromJSONKey)
-- | Tracks concurrent in-flight messages.
data InFlightInfo = InFlightInfo
{ -- | Currently processing count
inFlight :: !Int,
-- | Configured max concurrency (1 for Serial)
maxConcurrency :: !Int
}
deriving stock (Eq, Show, Generic)
instance ToJSON InFlightInfo where
toJSON info =
object
[ "inFlight" Aeson..= info.inFlight,
"maxConcurrency" Aeson..= info.maxConcurrency
]
instance FromJSON InFlightInfo where
parseJSON = withObject "InFlightInfo" $ \v ->
InFlightInfo
<$> v .: "inFlight"
<*> v .: "maxConcurrency"
-- | Create empty in-flight info with given max concurrency.
emptyInFlightInfo :: Int -> InFlightInfo
emptyInFlightInfo = InFlightInfo 0
-- | Processor runtime state.
data ProcessorState
= -- | Waiting for messages
Idle
| -- | Currently processing (in-flight info, last activity time)
Processing !InFlightInfo !UTCTime
| -- | Failed with error (error message, timestamp)
Failed !Text !UTCTime
| -- | Processor has been stopped
Stopped
deriving stock (Eq, Show, Generic)
instance ToJSON ProcessorState where
toJSON Idle = object ["status" Aeson..= ("idle" :: Text)]
toJSON (Processing info lastActivity) =
object
[ "status" Aeson..= ("processing" :: Text),
"inFlight" Aeson..= info.inFlight,
"maxConcurrency" Aeson..= info.maxConcurrency,
"lastActivity" Aeson..= lastActivity
]
toJSON (Failed err timestamp) =
object
[ "status" Aeson..= ("failed" :: Text),
"error" Aeson..= err,
"timestamp" Aeson..= timestamp
]
toJSON Stopped = object ["status" Aeson..= ("stopped" :: Text)]
instance FromJSON ProcessorState where
parseJSON = withObject "ProcessorState" $ \v -> do
status <- v .: "status"
case status :: Text of
"idle" -> pure Idle
"processing" -> do
inFlightCount <- v .: "inFlight"
maxConc <- v .: "maxConcurrency"
lastActivity <- v .: "lastActivity"
pure $ Processing (InFlightInfo inFlightCount maxConc) lastActivity
"failed" -> Failed <$> v .: "error" <*> v .: "timestamp"
"stopped" -> pure Stopped
other -> fail $ "Unknown processor state: " <> Text.unpack other
-- | Stream statistics.
data StreamStats = StreamStats
{ -- | Messages received from stream
received :: !Int,
-- | Messages successfully processed
processed :: !Int,
-- | Messages that failed processing
failed :: !Int
}
deriving stock (Eq, Show, Generic)
deriving anyclass (ToJSON, FromJSON)
-- | Empty stream stats.
emptyStreamStats :: StreamStats
emptyStreamStats = StreamStats 0 0 0
-- | Batch-processing statistics, tracked alongside per-message stream stats.
data BatchStats = BatchStats
{ -- | Number of batches emitted and executed.
batchesEmitted :: !Int,
-- | Total messages across all emitted batches.
batchedMessages :: !Int,
-- | Batches with a genuine partial failure: the handler returned normally
-- and named at least one message in its decision map with a failing
-- decision (dead-letter or retry) while acking the rest. Counted per batch,
-- not per message, so it does not double-count the per-message 'failed'
-- counter.
partialFailures :: !Int,
-- | Batches emitted because they reached the configured size.
sizeTriggered :: !Int,
-- | Batches emitted because their timeout elapsed.
timeoutTriggered :: !Int,
-- | Batches emitted because the processor was draining (flush).
flushTriggered :: !Int
}
deriving stock (Eq, Show, Generic)
deriving anyclass (ToJSON, FromJSON)
-- | Empty batch stats (all zero).
emptyBatchStats :: BatchStats
emptyBatchStats = BatchStats 0 0 0 0 0 0
-- | Combined processor metrics.
data ProcessorMetrics = ProcessorMetrics
{ -- | Current state
state :: !ProcessorState,
-- | Per-message statistics
stats :: !StreamStats,
-- | Batch statistics
batch :: !BatchStats,
-- | When the processor started
startedAt :: !UTCTime
}
deriving stock (Eq, Show, Generic)
deriving anyclass (ToJSON, FromJSON)
-- | Empty processor metrics.
emptyProcessorMetrics :: UTCTime -> ProcessorMetrics
emptyProcessorMetrics now =
ProcessorMetrics
{ state = Idle,
stats = emptyStreamStats,
batch = emptyBatchStats,
startedAt = now
}
-- | Map of processor IDs to their metrics.
type MetricsMap = Map ProcessorId ProcessorMetrics
-- | Hot per-message counters for one processor.
--
-- These counters are updated by fetch-and-add operations on the message hot
-- path. The colder 'ProcessorMetrics' TVar inside 'MetricsHandle' stores state,
-- batch counters, and metadata that change less frequently.
data HotCounters = HotCounters
{ received :: !AtomicCounter,
processed :: !AtomicCounter,
failed :: !AtomicCounter,
inFlight :: !AtomicCounter
}
-- | Write-side metrics handle for one processor.
data MetricsHandle = MetricsHandle
{ hot :: !HotCounters,
maxConcurrencyRef :: !(IORef Int),
burstStartedRef :: !(IORef UTCTime),
stateActiveRef :: !(IORef Bool),
cold :: !(TVar ProcessorMetrics)
}
newMetricsHandle :: UTCTime -> IO MetricsHandle
newMetricsHandle now = do
received <- Counter.newCounter 0
processed <- Counter.newCounter 0
failed <- Counter.newCounter 0
inFlight <- Counter.newCounter 0
maxConcurrencyRef <- newIORef 1
burstStartedRef <- newIORef now
stateActiveRef <- newIORef False
cold <- newTVarIO (emptyProcessorMetrics now)
pure
MetricsHandle
{ hot =
HotCounters
{ received = received,
processed = processed,
failed = failed,
inFlight = inFlight
},
maxConcurrencyRef = maxConcurrencyRef,
burstStartedRef = burstStartedRef,
stateActiveRef = stateActiveRef,
cold = cold
}
sampleMetrics :: MetricsHandle -> IO ProcessorMetrics
sampleMetrics handle = do
coldSnapshot <- readTVarIO handle.cold
received <- readCounter handle.hot.received
processed <- readCounter handle.hot.processed
failed <- readCounter handle.hot.failed
inFlight <- readCounter handle.hot.inFlight
maxConcurrency <- readIORef handle.maxConcurrencyRef
burstStartedAt <- readIORef handle.burstStartedRef
let sampledStats =
StreamStats
{ received = received,
processed = processed,
failed = failed
}
sampledState = case coldSnapshot.state of
Failed err timestamp -> Failed err timestamp
Stopped -> Stopped
_ | inFlight > 0 -> Processing (InFlightInfo inFlight maxConcurrency) burstStartedAt
_ -> Idle
pure coldSnapshot {state = sampledState, stats = sampledStats}
incrementReceived :: MetricsHandle -> IO ()
incrementReceived handle =
void $ incrCounter 1 handle.hot.received
beginProcessing :: MetricsHandle -> Int -> IO Int
beginProcessing handle maxConcurrency = do
currentInflight <- incrCounter 1 handle.hot.inFlight
when (currentInflight == 1) $ do
stateActive <- readIORef handle.stateActiveRef
unless stateActive $ do
now <- getCurrentTime
writeIORef handle.maxConcurrencyRef maxConcurrency
writeIORef handle.burstStartedRef now
writeIORef handle.stateActiveRef True
atomically $
modifyTVar' handle.cold $ \m ->
m {state = Processing (InFlightInfo currentInflight maxConcurrency) now}
pure currentInflight
finishProcessing :: MetricsHandle -> Either Text AckDecisionMetric -> IO ()
finishProcessing handle result = do
case result of
Right CountProcessed -> void $ incrCounter 1 handle.hot.processed
Right CountFailed -> void $ incrCounter 1 handle.hot.failed
Right CountNeither -> pure ()
Right (CountHalt _) -> pure ()
Left _ -> void $ incrCounter 1 handle.hot.failed
void $ incrCounter (-1) handle.hot.inFlight
case result of
Left failureText -> setFailed failureText
Right (CountHalt reasonText) -> setFailed reasonText
_ -> pure ()
where
setFailed failureText = do
now <- getCurrentTime
writeIORef handle.stateActiveRef False
atomically $
modifyTVar' handle.cold $ \m ->
m {state = Failed failureText now}
finishFinalizationFailure :: MetricsHandle -> Text -> IO ()
finishFinalizationFailure handle failureText =
finishProcessing handle (Left failureText)
data AckDecisionMetric
= CountProcessed
| CountFailed
| CountNeither
| CountHalt !Text
recordBatchOutcomeMetrics ::
MetricsHandle ->
BatchTriggerMetric ->
Int ->
Bool ->
Bool ->
[AckDecisionMetric] ->
Maybe Text ->
IO ()
recordBatchOutcomeMetrics handle trigger size handlerThrew partialInc decisions firstHalt = do
let (processedDelta, failedDelta) =
foldl'
( \(processedAcc, failedAcc) decision ->
if handlerThrew
then (processedAcc, failedAcc + 1)
else case decision of
CountProcessed -> (processedAcc + 1, failedAcc)
CountFailed -> (processedAcc, failedAcc + 1)
CountNeither -> (processedAcc, failedAcc)
CountHalt _ -> (processedAcc, failedAcc)
)
(0, 0)
decisions
when (processedDelta /= 0) $
void $
incrCounter processedDelta handle.hot.processed
when (failedDelta /= 0) $
void $
incrCounter failedDelta handle.hot.failed
void $ incrCounter (-1) handle.hot.inFlight
now <- getCurrentTime
atomically $
modifyTVar' handle.cold $ \m ->
let newState = case firstHalt of
Just reasonText -> Failed reasonText now
Nothing -> case m.state of
Failed {} -> m.state
Stopped -> Stopped
_ -> m.state
newBatch =
incTriggerMetric trigger
. (if partialInc then incPartialFailures else id)
. addBatchedMessages size
. incBatchesEmitted
$ m.batch
in m {state = newState, batch = newBatch}
case firstHalt of
Just _ -> writeIORef handle.stateActiveRef False
Nothing -> pure ()
data BatchTriggerMetric
= CountTriggerSize
| CountTriggerTimeout
| CountTriggerFlush
incTriggerMetric :: BatchTriggerMetric -> BatchStats -> BatchStats
incTriggerMetric CountTriggerSize = incSizeTriggered
incTriggerMetric CountTriggerTimeout = incTimeoutTriggered
incTriggerMetric CountTriggerFlush = incFlushTriggered
-- | Increment received count.
incReceived :: StreamStats -> StreamStats
incReceived StreamStats {received, processed, failed} =
StreamStats {received = received + 1, processed = processed, failed = failed}
-- | Increment processed count.
incProcessed :: StreamStats -> StreamStats
incProcessed StreamStats {received, processed, failed} =
StreamStats {received = received, processed = processed + 1, failed = failed}
-- | Increment failed count.
incFailed :: StreamStats -> StreamStats
incFailed StreamStats {received, processed, failed} =
StreamStats {received = received, processed = processed, failed = failed + 1}
-- | Increment the emitted-batch counter.
incBatchesEmitted :: BatchStats -> BatchStats
incBatchesEmitted s = s {batchesEmitted = s.batchesEmitted + 1}
-- | Add to the total batched-messages counter.
addBatchedMessages :: Int -> BatchStats -> BatchStats
addBatchedMessages n s = s {batchedMessages = s.batchedMessages + n}
-- | Increment the partial-failure batch counter.
incPartialFailures :: BatchStats -> BatchStats
incPartialFailures s = s {partialFailures = s.partialFailures + 1}
-- | Increment the size-trigger counter.
incSizeTriggered :: BatchStats -> BatchStats
incSizeTriggered s = s {sizeTriggered = s.sizeTriggered + 1}
-- | Increment the timeout-trigger counter.
incTimeoutTriggered :: BatchStats -> BatchStats
incTimeoutTriggered s = s {timeoutTriggered = s.timeoutTriggered + 1}
-- | Increment the flush-trigger counter.
incFlushTriggered :: BatchStats -> BatchStats
incFlushTriggered s = s {flushTriggered = s.flushTriggered + 1}