glue-prometheus-0.8.0.0: src/Glue/Prometheus.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Module recording Prometheus metrics for services and glue's combinators through
-- "Prometheus" (prometheus-client).
--
-- Metrics are registered once with the @register...Metrics@ functions and shared by every service
-- or combinator they instrument, each telling them apart by a caller-supplied name. Names follow
-- Prometheus conventions and every label has bounded cardinality: service, breaker and limiter
-- names are chosen by the caller, attempt indices are bounded by the number of backends, and
-- request keys are never used as labels.
--
-- [@glue_service_calls_total{service}@] Calls started.
-- [@glue_service_failures_total{service}@] Calls that failed (a synchronous exception, or @Left@).
-- [@glue_service_duration_seconds{service}@] Histogram of the duration of calls that completed.
-- [@glue_circuit_breaker_state{breaker}@] 0 closed, 1 half-open, 2 open.
-- [@glue_circuit_breaker_transitions_total{breaker,from,to}@] Phase changes (@closed@, @half_open@, @open@).
-- [@glue_retries_total{service}@] Retries made.
-- [@glue_retry_delay_seconds{service}@] Histogram of the wait chosen before each retry.
-- [@glue_concurrency_in_flight{limiter}@] Slots currently held.
-- [@glue_concurrency_refusals_total{limiter}@] Calls refused because their key was full.
-- [@glue_hedge_launches_total{service,attempt}@] Backups launched.
-- [@glue_hedge_attempts_total{service,attempt,outcome}@] Attempts ended (@answered@, @failed@, @cancelled@, @crashed@).
-- [@glue_hedge_wins_total{service,attempt}@] Answers returned, by the attempt that won.
-- [@glue_shutdown_in_flight{service}@] Calls in flight through a graceful shutdown wrapper, sampled at scrape.
module Glue.Prometheus
( -- * Services
ServiceMetrics (..)
, registerServiceMetrics
, instrumentService
, instrumentServiceEither
-- * Circuit breakers
, BreakerMetrics (..)
, registerBreakerMetrics
, instrumentBreakerOptions
-- * Retries
, RetryMetrics (..)
, registerRetryMetrics
, instrumentRetryOptions
-- * Concurrency limits
, ConcurrencyMetrics (..)
, registerConcurrencyMetrics
, instrumentAdmissionStore
-- * Hedging
, HedgeMetrics (..)
, registerHedgeMetrics
, instrumentHedgeOptions
-- * Graceful shutdown
, shutdownInFlightMetric
) where
import qualified Data.ByteString.Char8 as BS8
import Data.Text (Text, pack)
import GHC.Clock (getMonotonicTimeNSec)
import Glue.CircuitBreaker (BreakerPhase (..),
BreakerTransition (..),
CircuitBreakerOptions (..))
import Glue.ConcurrencyLimit (AdmissionLease (..),
AdmissionStore (..))
import Glue.Failure (EitherService)
import Glue.Hedging (AttemptOutcome (..), HedgeOptions (..))
import Glue.Retry (RetryOptions (..))
import Glue.Shutdown (ShutdownHandle (..))
import Glue.Types (BasicService)
import Prometheus (Counter, Gauge, Histogram, Info (..),
Metric (..), Sample (..),
SampleGroup (..), SampleType (..),
Vector, addGauge, counter,
defaultBuckets, gauge, histogram,
incCounter, observe, register,
setGauge, vector, withLabel)
import UnliftIO (MonadIO, MonadUnliftIO, liftIO)
import UnliftIO.Exception (throwIO, tryAny)
secondsSince :: MonadIO m => m (m Double)
secondsSince = do
start <- liftIO getMonotonicTimeNSec
pure $ do
end <- liftIO getMonotonicTimeNSec
pure (fromIntegral (end - start) / 1e9)
-- | Metrics for instrumented services.
data ServiceMetrics = ServiceMetrics
{ serviceCalls :: Vector Text Counter
, serviceFailures :: Vector Text Counter
, serviceDuration :: Vector Text Histogram
}
-- | Registers the service metrics with the global registry.
registerServiceMetrics :: MonadIO n => n ServiceMetrics
registerServiceMetrics =
ServiceMetrics
<$> register (vector "service" (counter (Info "glue_service_calls_total" "Calls started.")))
<*> register (vector "service" (counter (Info "glue_service_failures_total" "Calls that failed.")))
<*> register (vector "service" (histogram (Info "glue_service_duration_seconds" "Duration of calls that completed.") defaultBuckets))
instrumentVia :: MonadUnliftIO m => (r -> Bool) -> ServiceMetrics -> Text -> BasicService m a r -> BasicService m a r
instrumentVia failed metrics name service request = do
liftIO $ withLabel (serviceCalls metrics) name incCounter
elapsed <- secondsSince
let finish isFailure = do
seconds <- elapsed
liftIO $ do
withLabel (serviceDuration metrics) name (`observe` seconds)
if isFailure then withLabel (serviceFailures metrics) name incCounter else pure ()
outcome <- tryAny (service request)
case outcome of
Left e -> finish True >> throwIO e
Right result -> finish (failed result) >> pure result
-- | Records calls, failures (synchronous exceptions) and the duration of completed calls under the
-- given service name. Calls cancelled by an asynchronous exception are counted as calls only.
instrumentService :: MonadUnliftIO m => ServiceMetrics -> Text -> BasicService m a b -> BasicService m a b
instrumentService = instrumentVia (const False)
-- | Variant of 'instrumentService' for services returning failures as values, counting @Left@
-- results (as well as exceptions) as failures.
instrumentServiceEither :: MonadUnliftIO m => ServiceMetrics -> Text -> EitherService m a e b -> EitherService m a e b
instrumentServiceEither = instrumentVia (either (const True) (const False))
-- | Metrics for circuit breakers.
data BreakerMetrics = BreakerMetrics
{ breakerState :: Vector Text Gauge
, breakerTransitions :: Vector (Text, Text, Text) Counter
}
-- | Registers the circuit breaker metrics with the global registry.
registerBreakerMetrics :: MonadIO n => n BreakerMetrics
registerBreakerMetrics =
BreakerMetrics
<$> register (vector "breaker" (gauge (Info "glue_circuit_breaker_state" "Circuit breaker phase: 0 closed, 1 half-open, 2 open.")))
<*> register (vector ("breaker", "from", "to") (counter (Info "glue_circuit_breaker_transitions_total" "Circuit breaker phase changes.")))
phaseValue :: BreakerPhase -> Double
phaseValue BreakerClosed = 0
phaseValue BreakerHalfOpen = 1
phaseValue BreakerOpen = 2
phaseLabel :: BreakerPhase -> Text
phaseLabel BreakerClosed = "closed"
phaseLabel BreakerHalfOpen = "half_open"
phaseLabel BreakerOpen = "open"
-- | Records the breaker's phase and its transitions under the given name, keeping any existing
-- 'onBreakerTransition'. The phase gauge starts at closed.
instrumentBreakerOptions :: (MonadIO n, MonadIO m) => BreakerMetrics -> Text -> CircuitBreakerOptions m failure -> n (CircuitBreakerOptions m failure)
instrumentBreakerOptions metrics name options = do
liftIO $ withLabel (breakerState metrics) name (`setGauge` phaseValue BreakerClosed)
pure options
{ onBreakerTransition = \transition -> do
liftIO $ do
withLabel (breakerState metrics) name (`setGauge` phaseValue (transitionTo transition))
withLabel (breakerTransitions metrics) (name, phaseLabel (transitionFrom transition), phaseLabel (transitionTo transition)) incCounter
onBreakerTransition options transition
}
-- | Metrics for retries.
data RetryMetrics = RetryMetrics
{ retryCount :: Vector Text Counter
, retryDelay :: Vector Text Histogram
}
-- | Registers the retry metrics with the global registry.
registerRetryMetrics :: MonadIO n => n RetryMetrics
registerRetryMetrics =
RetryMetrics
<$> register (vector "service" (counter (Info "glue_retries_total" "Retries made.")))
<*> register (vector "service" (histogram (Info "glue_retry_delay_seconds" "Wait chosen before each retry.") defaultBuckets))
-- | Records each retry and its wait under the given service name, keeping any existing 'onRetry'.
instrumentRetryOptions :: MonadIO m => RetryMetrics -> Text -> RetryOptions m a failure -> RetryOptions m a failure
instrumentRetryOptions metrics name options =
options
{ onRetry = \attempt failure delayMs -> do
liftIO $ do
withLabel (retryCount metrics) name incCounter
withLabel (retryDelay metrics) name (`observe` (fromIntegral delayMs / 1000))
onRetry options attempt failure delayMs
}
-- | Metrics for keyed concurrency limits.
data ConcurrencyMetrics = ConcurrencyMetrics
{ concurrencyInFlight :: Vector Text Gauge
, concurrencyRefusals :: Vector Text Counter
}
-- | Registers the concurrency limit metrics with the global registry.
registerConcurrencyMetrics :: MonadIO n => n ConcurrencyMetrics
registerConcurrencyMetrics =
ConcurrencyMetrics
<$> register (vector "limiter" (gauge (Info "glue_concurrency_in_flight" "Slots currently held.")))
<*> register (vector "limiter" (counter (Info "glue_concurrency_refusals_total" "Calls refused because their key was full.")))
-- | Records slots held and refusals for every key of the store under one limiter name.
instrumentAdmissionStore :: MonadIO m => ConcurrencyMetrics -> Text -> AdmissionStore m k -> AdmissionStore m k
instrumentAdmissionStore metrics name store =
store
{ claimSlot = \key -> do
claimed <- claimSlot store key
case claimed of
Nothing -> do
liftIO $ withLabel (concurrencyRefusals metrics) name incCounter
pure Nothing
Just lease -> do
liftIO $ withLabel (concurrencyInFlight metrics) name (`addGauge` 1)
pure $ Just lease
{ releaseLease = releaseLease lease >> liftIO (withLabel (concurrencyInFlight metrics) name (`addGauge` (-1)))
}
}
-- | Metrics for hedged requests.
data HedgeMetrics = HedgeMetrics
{ hedgeLaunches :: Vector (Text, Text) Counter
, hedgeAttempts :: Vector (Text, Text, Text) Counter
, hedgeWins :: Vector (Text, Text) Counter
}
-- | Registers the hedging metrics with the global registry.
registerHedgeMetrics :: MonadIO n => n HedgeMetrics
registerHedgeMetrics =
HedgeMetrics
<$> register (vector ("service", "attempt") (counter (Info "glue_hedge_launches_total" "Backups launched.")))
<*> register (vector ("service", "attempt", "outcome") (counter (Info "glue_hedge_attempts_total" "Attempts ended, by outcome.")))
<*> register (vector ("service", "attempt") (counter (Info "glue_hedge_wins_total" "Answers returned, by the attempt that won.")))
outcomeLabel :: AttemptOutcome failure r -> Text
outcomeLabel (AttemptAnswered _) = "answered"
outcomeLabel (AttemptFailed _) = "failed"
outcomeLabel AttemptCancelled = "cancelled"
outcomeLabel (AttemptCrashed _) = "crashed"
-- | Records backup launches, how each attempt ended and which attempt won under the given service
-- name, keeping any existing hooks.
instrumentHedgeOptions :: MonadIO m => HedgeMetrics -> Text -> HedgeOptions m failure r -> HedgeOptions m failure r
instrumentHedgeOptions metrics name options =
options
{ admitLaunch = \index -> do
admitted <- admitLaunch options index
if admitted then liftIO (withLabel (hedgeLaunches metrics) (name, attemptLabel index) incCounter) else pure ()
pure admitted
, onAttemptEnded = \index outcome -> do
liftIO $ withLabel (hedgeAttempts metrics) (name, attemptLabel index, outcomeLabel outcome) incCounter
onAttemptEnded options index outcome
, onHedgeWon = \index -> do
liftIO $ withLabel (hedgeWins metrics) (name, attemptLabel index) incCounter
onHedgeWon options index
}
where
attemptLabel = pack . show
-- | A metric sampling the in-flight count of each named graceful shutdown wrapper at scrape time.
-- Register it once with every handle to report.
shutdownInFlightMetric :: [(Text, ShutdownHandle IO)] -> Metric ()
shutdownInFlightMetric handles = Metric $ pure ((), collect)
where
info = Info "glue_shutdown_in_flight" "Calls in flight through a graceful shutdown wrapper."
collect = do
samples <- traverse sample handles
pure [SampleGroup info GaugeType samples]
sample (name, handle) = do
count <- inFlightCount handle
pure $ Sample (metricName info) [("service", name)] (BS8.pack (show (fromIntegral count :: Double)))