prodapi (empty) → 0.1.0.0
raw patch · 16 files changed
+1436/−0 lines, 16 filesdep +aesondep +asyncdep +basesetup-changed
Dependencies added: aeson, async, base, bytestring, containers, contravariant, directory, http-api-data, http-client, http-media, lucid, process-extras, prometheus-client, prometheus-metrics-ghc, servant, servant-client, servant-server, text, time, uuid, wai
Files
- CHANGELOG.md +5/−0
- LICENSE +30/−0
- Setup.hs +3/−0
- prodapi.cabal +57/−0
- src/Prod/App.hs +86/−0
- src/Prod/Background.hs +106/−0
- src/Prod/Discovery.hs +117/−0
- src/Prod/Echo.hs +22/−0
- src/Prod/Health.hs +132/−0
- src/Prod/Healthcheck.hs +324/−0
- src/Prod/MimeTypes.hs +41/−0
- src/Prod/Prometheus.hs +82/−0
- src/Prod/Reports.hs +78/−0
- src/Prod/Status.hs +148/−0
- src/Prod/Tracer.hs +112/−0
- src/Prod/Watchdog.hs +93/−0
+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for prodapi++## 0.1.0.0 -- YYYY-mm-dd++* First version. Released on an unsuspecting world.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Lucas DiCioccio (c) 2021++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Author name here nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,3 @@+import Distribution.Simple++main = defaultMain
+ prodapi.cabal view
@@ -0,0 +1,57 @@+cabal-version: >=1.10+name: prodapi+version: 0.1.0.0+synopsis: Some curated and opinionated packages for building Haskell services.+description: A library of curated and opinionated packages for building Haskell serivces, with some preferred pattern. Services expose metrics using prometheus and log events via contravariant logging.+-- bug-reports:+license: BSD3+license-file: LICENSE+author: Lucas DiCioccio+maintainer: lucas@dicioccio.fr+-- copyright:+category: System+build-type: Simple+extra-source-files: CHANGELOG.md++library+ exposed-modules:+ Prod.App+ Prod.Background+ Prod.Discovery+ Prod.Echo+ Prod.Health+ Prod.Healthcheck+ Prod.MimeTypes+ Prod.Prometheus+ Prod.Reports+ Prod.Status+ Prod.Tracer+ Prod.Watchdog+ Paths_prodapi+ -- other-modules:+ hs-source-dirs:+ src+ default-extensions: OverloadedStrings DataKinds TypeApplications TypeOperators+ build-depends:+ base >= 4.7 && <5,+ aeson >= 2.2.1 && < 2.3,+ containers >= 0.6.8 && < 0.7,+ bytestring >= 0.12.1 && < 0.13,+ text >= 2.1.1 && < 2.2,+ contravariant >= 1.5.5 && < 1.6,+ time >= 1.12.2 && < 1.13,+ async >= 2.2.5 && < 2.3,+ directory >= 1.3.8 && < 1.4,+ http-api-data >= 0.6 && < 0.7,+ http-client >= 0.7.17 && < 0.8,+ http-media >= 0.8.1 && < 0.9,+ lucid >= 2.11.20230408 && < 2.12,+ process-extras >= 0.7.4 && < 0.8,+ prometheus-client >= 1.1.1 && < 1.2,+ prometheus-metrics-ghc >= 1.0.1 && < 1.1,+ servant >= 0.20.1 && < 0.21,+ servant-client >= 0.20 && < 0.21,+ servant-server >= 0.20 && < 0.21,+ wai >= 3.2.4 && < 3.3,+ uuid >= 1.3.15 && < 1.4+ default-language: Haskell2010
+ src/Prod/App.hs view
@@ -0,0 +1,86 @@+{-# LANGUAGE FlexibleContexts #-}++module Prod.App (+ app,+ appWithContext,+ initialize,+ Init,+ Runtime (..),+ alwaysReadyRuntime,+)+where++import Data.Aeson (ToJSON)+import Data.Proxy (Proxy (..))+import Prod.Health+import Prod.Prometheus+import Prod.Status+import Servant+import Servant.Server++-- | Run a full API, with raw-serving.+type AppApi status api =+ HealthApi+ :<|> StatusApi status+ :<|> PrometheusApi+ :<|> api+ :<|> Raw++-- | Opaque proof of initialization.+data Init = Init Runtime++-- | Initializes internal data.+initialize :: Runtime -> IO Init+initialize runtime =+ initPrometheus >> pure (Init runtime)++-- | Application.+app ::+ ( HasServer api '[]+ , ToJSON status+ ) =>+ Init ->+ IO status ->+ RenderStatus status ->+ Server api ->+ Proxy api ->+ Application+app (Init runtime) getStatus renderStatus appHandler proxy0 =+ serve+ (proxy proxy0)+ ( handleHealth runtime+ :<|> handleStatus runtime getStatus renderStatus+ :<|> handlePrometheus (CORSAllowOrigin "*")+ :<|> appHandler+ :<|> serveDirectoryFileServer "www"+ )+ where+ proxy :: Proxy y -> Proxy (AppApi status y)+ proxy _ = Proxy++-- | Application.+appWithContext ::+ ( HasServer api context+ , HasContextEntry (context .++ DefaultErrorFormatters) ErrorFormatters+ , ToJSON status+ ) =>+ Init ->+ IO status ->+ RenderStatus status ->+ Server api ->+ Proxy api ->+ Context context ->+ Application+appWithContext (Init runtime) getStatus renderStatus appHandler proxy0 context =+ serveWithContext+ (proxy proxy0)+ context+ ( handleHealth runtime+ :<|> handleStatus runtime getStatus renderStatus+ :<|> handlePrometheus (CORSAllowOrigin "*")+ :<|> appHandler+ :<|> serveDirectoryFileServer "www"+ )+ where+ proxy :: Proxy x -> Proxy (AppApi status x)+ proxy _ = Proxy
+ src/Prod/Background.hs view
@@ -0,0 +1,106 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE NumericUnderscores #-}++module Prod.Background (+ BackgroundVal,+ MicroSeconds,+ background,+ backgroundLoop,+ kill,+ link,+ readBackgroundVal,+ Track (..),+)+where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (Async, async, cancel)+import qualified Control.Concurrent.Async as Async+import Control.Monad (forever)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Prod.Tracer (Tracer (..))++import GHC.Stack (CallStack, HasCallStack, callStack)++data Track r+ = Init r+ | RunStart+ | RunDone r r+ | Kill CallStack+ deriving (Show, Functor)++-- | A value that is coupled to an async in charge of updating the value.+data BackgroundVal a+ = forall r.+ BackgroundVal+ { transform :: r -> a+ -- ^ a transformation to apply to the background val, allows to turn the IORef into a functor+ , currentValue :: IORef r+ -- ^ a mutable reference for the latest value+ , backgroundTask :: Async ()+ -- ^ a background task responsible for updating the value, implementations should guarantee that once the Aync () is cancelled, currentValue is never updated+ , tracer :: Tracer IO (Track r)+ }++instance Functor BackgroundVal where+ fmap g (BackgroundVal f ioref task tracer) =+ BackgroundVal (g . f) ioref task tracer++-- | Starts a background task continuously updating a value.+background ::+ Tracer IO (Track a) ->+ -- | initial state+ b ->+ -- | initial value+ a ->+ -- | state-influenced task+ (b -> IO (a, b)) ->+ IO (BackgroundVal a)+background tracer initState defaultValue task = do+ trace (Init defaultValue)+ ref <- newIORef defaultValue+ BackgroundVal id ref <$> async (loop ref initState) <*> pure tracer+ where+ trace = runTracer tracer+ loop ref st0 = do+ trace (RunStart)+ (newVal, st1) <- task st0+ oldVal <- seq newVal $ atomicModifyIORef' ref (\old -> (newVal, old))+ trace (RunDone oldVal newVal)+ seq st1 $ loop ref st1++-- | Fantom type for annotating Int.+type MicroSeconds n = n++{- | Starts a background task continuously updating a value at a periodic interval.+This is implemented by interspersing a threadDelay before the task and calling background and hiding the 'state-passing' arguments.+-}+backgroundLoop ::+ Tracer IO (Track a) ->+ -- | initial value+ a ->+ -- | periodic task+ IO a ->+ -- | wait period between two executions+ MicroSeconds Int ->+ IO (BackgroundVal a)+backgroundLoop tracer defaultValue task usecs = do+ background tracer () defaultValue (\_ -> threadDelay usecs >> fmap adapt task)+ where+ adapt x = (x, ())++-- | Kills the watchdog by killing the underlying async.+readBackgroundVal :: (MonadIO m) => BackgroundVal a -> m a+readBackgroundVal (BackgroundVal f ioref _ _) =+ fmap f $ liftIO $ readIORef ioref++-- | Kills the watchdog by killing the underlying async.+kill :: (HasCallStack, MonadIO m) => BackgroundVal a -> m ()+kill bkg@(BackgroundVal _ _ _ tracer) = liftIO $ do+ runTracer tracer (Kill callStack)+ liftIO $ cancel . backgroundTask $ bkg++link :: BackgroundVal a -> BackgroundVal b -> IO ()+link b1 b2 = Async.link2 (backgroundTask b1) (backgroundTask b2)
+ src/Prod/Discovery.hs view
@@ -0,0 +1,117 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE NumericUnderscores #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Module for performing service (endpoints) discovery.+module Prod.Discovery where++import Control.Concurrent (threadDelay)+import Data.ByteString (ByteString)+import qualified Data.ByteString as ByteString+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import Data.Time.Clock (UTCTime, getCurrentTime)++import Prod.Background (BackgroundVal, MicroSeconds, background, readBackgroundVal)+import qualified Prod.Background+import Prod.Tracer (Tracer (..), contramap)+import Prometheus (Counter, Gauge, Label3, Vector)+import qualified Prometheus as Prometheus+import System.Process.ByteString (readProcessWithExitCode)++data Track a = BackgroundTrack (Prod.Background.Track (Result a))+ deriving (Show, Functor)++data Result a+ = NotAsked+ | Asked UTCTime+ | Found UTCTime a+ deriving (Show, Functor)++toMaybe :: Result a -> Maybe a+toMaybe (Found _ a) = Just a+toMaybe _ = Nothing++data Discovery a = Discovery (BackgroundVal (Result a))+ deriving (Functor)++readCurrent :: Discovery a -> IO (Result a)+readCurrent (Discovery b) = readBackgroundVal b++type Host = Text++data DNSTrack a = DNSTrack Text Host (Track a)+ deriving (Show, Functor)++dnsA :: Tracer IO (DNSTrack [Host]) -> Host -> IO (Discovery [Host])+dnsA tracer hostname = dig (contramap (DNSTrack hostname "A") tracer) "A" $ Text.unpack hostname++dnsAAAA :: Tracer IO (DNSTrack [Host]) -> Host -> IO (Discovery [Host])+dnsAAAA tracer hostname = dig (contramap (DNSTrack hostname "AAAA") tracer) "AAAA" $ Text.unpack hostname++dig :: Tracer IO (Track [Host]) -> String -> String -> IO (Discovery [Host])+dig tracer typ target = cmdOut tracer "dig" ["+short", typ, target] "" tenSecs [] replaceHosts trigger+ where+ replaceHosts :: [Host] -> ByteString -> [Host]+ replaceHosts _ = parseHosts++ trigger :: [Host] -> [Host] -> IO ()+ trigger _ xs = do+ Prometheus.withLabel+ dnsDiscoveryCounter+ promLabels+ Prometheus.incCounter+ Prometheus.withLabel+ dnsDiscoveryGauge+ promLabels+ (flip Prometheus.setGauge (fromIntegral $ length xs))++ promLabels :: Label3+ promLabels = ("dig", Text.pack typ, Text.pack target)++ parseHosts :: ByteString -> [Host]+ parseHosts = Text.lines . Text.decodeUtf8++ tenSecs :: Maybe (MicroSeconds Int)+ tenSecs = Just 10_000_000++{-# NOINLINE dnsDiscoveryGauge #-}+dnsDiscoveryGauge :: Vector Label3 Gauge+dnsDiscoveryGauge =+ Prometheus.unsafeRegister $+ Prometheus.vector ("m", "type", "host") $+ Prometheus.gauge (Prometheus.Info "prodapi_dns_discovery_results" "")++{-# NOINLINE dnsDiscoveryCounter #-}+dnsDiscoveryCounter :: Vector Label3 Counter+dnsDiscoveryCounter =+ Prometheus.unsafeRegister $+ Prometheus.vector ("m", "type", "host") $+ Prometheus.counter (Prometheus.Info "prodapi_dns_discoveries" "")++cmdOut ::+ forall a.+ Tracer IO (Track a) ->+ String -> -- program to run+ [String] -> -- arguments to the program to run+ ByteString -> -- input submitted to the program+ Maybe (MicroSeconds Int) -> -- delay between invocations+ a ->+ (a -> ByteString -> a) ->+ (a -> a -> IO ()) ->+ IO (Discovery a)+cmdOut tracer cmd args input ms st0 update trigger =+ Discovery <$> background (contramap BackgroundTrack tracer) st0 NotAsked run+ where+ run :: a -> IO (Result a, a)+ run st0 = do+ maybe (pure ()) threadDelay ms+ out <- runCmd+ let st1 = update st0 out+ now <- getCurrentTime+ seq st1 $ trigger st0 st1+ pure $ (Found now st1, st1)++ runCmd :: IO ByteString+ runCmd = (\(_, x, _) -> x) <$> readProcessWithExitCode cmd args input
+ src/Prod/Echo.hs view
@@ -0,0 +1,22 @@+{-# LANGUAGE KindSignatures #-}++module Prod.Echo (+ EchoApi,+ handleEcho,+)+where++import Data.Aeson (FromJSON, ToJSON)+import GHC.TypeLits (Symbol)+import Servant (JSON, Post, ReqBody, Summary, (:>))+import Servant.Server (Handler)++type EchoApi (segment :: Symbol) a =+ Summary "returns the input"+ :> "echo"+ :> segment+ :> ReqBody '[JSON] a+ :> Post '[JSON] a++handleEcho :: (FromJSON a, ToJSON a) => a -> Handler a+handleEcho = pure
+ src/Prod/Health.hs view
@@ -0,0 +1,132 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DerivingVia #-}+{-# LANGUAGE StandaloneDeriving #-}++module Prod.Health (+ HealthApi,+ GetReadinessApi,+ handleHealth,+ Liveness (..),+ Reason (..),+ Readiness (..),+ completeReadiness,+ Runtime (..),+ alwaysReadyRuntime,+ withLiveness,+ withReadiness,+ Track (..),+)+where++import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Aeson (FromJSON, ToJSON (..), Value (String))+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Text (Text)+import GHC.Generics (Generic)+import GHC.Stack (CallStack, HasCallStack, callStack)+import Prod.Tracer+import Servant++data Runtime+ = Runtime+ { liveness :: IO Liveness+ , readiness :: IO Readiness+ , conditions :: IORef (Set Reason)+ , tracer :: Tracer IO Track+ }++alwaysReadyRuntime :: Tracer IO Track -> IO Runtime+alwaysReadyRuntime tracer = Runtime (pure Alive) (pure Ready) <$> (newIORef mempty) <*> pure tracer++withReadiness :: IO Readiness -> Runtime -> Runtime+withReadiness io rt = rt{readiness = io}++withLiveness :: IO Liveness -> Runtime -> Runtime+withLiveness io rt = rt{liveness = io}++data Liveness = Alive++instance ToJSON Liveness where+ toJSON = const $ String "alive"++newtype Reason = Reason Text+ deriving stock (Eq, Ord, Show)+ deriving+ (ToJSON, FromJSON)+ via Text++data Readiness = Ready | Ill (Set Reason)+ deriving stock (Eq, Ord, Show)+ deriving (Generic)++instance ToJSON Readiness+instance FromJSON Readiness++data Track = Afflict CallStack Reason | Cure CallStack Reason+ deriving (Show)++trace :: (HasCallStack, MonadIO m) => Runtime -> Track -> m ()+trace rt = liftIO . runTracer (tracer rt)++combineReasons :: Readiness -> Set Reason -> Readiness+combineReasons Ready rs+ | Set.null rs = Ready+ | otherwise = Ill rs+combineReasons (Ill rs1) rs2 = Ill (rs1 <> rs2)++completeReadiness :: Runtime -> IO Readiness+completeReadiness rt =+ combineReasons <$> readiness rt <*> readIORef (conditions rt)++-- | Add some illness reason.+afflict :: (MonadIO m, HasCallStack) => Runtime -> Reason -> m ()+afflict rt r = do+ trace rt $ Afflict callStack r+ liftIO $ atomicModifyIORef' (conditions rt) (\rs -> (Set.insert r rs, ()))++-- | Remove some illness reason.+cure :: (MonadIO m, HasCallStack) => Runtime -> Reason -> m ()+cure rt r = do+ trace rt $ Cure callStack r+ liftIO $ atomicModifyIORef' (conditions rt) (\rs -> (Set.delete r rs, ()))++type GetLivenessApi =+ Summary "Health liveness probe."+ :> "health"+ :> "alive"+ :> Get '[JSON] Liveness++type GetReadinessApi =+ Summary "Health readiness probe."+ :> "health"+ :> "ready"+ :> Get '[JSON] Readiness++type DrainApi =+ Summary "Set a specific 'drained' condition."+ :> "health"+ :> "drain"+ :> Post '[JSON] Readiness++type HealthApi =+ GetLivenessApi+ :<|> GetReadinessApi+ :<|> DrainApi++handleHealth :: Runtime -> Server HealthApi+handleHealth runtime =+ handleLiveness runtime+ :<|> handleReadiness runtime+ :<|> handleDrain runtime+ where+ handleLiveness :: Runtime -> Handler Liveness+ handleLiveness = liftIO . liveness+ handleReadiness :: Runtime -> Handler Readiness+ handleReadiness rt = liftIO $ do+ completeReadiness rt+ handleDrain :: Runtime -> Handler Readiness+ handleDrain rt = do+ afflict rt (Reason "drained")+ handleReadiness rt
+ src/Prod/Healthcheck.hs view
@@ -0,0 +1,324 @@+{-# LANGUAGE DeriveGeneric #-}++module Prod.Healthcheck where++import Control.Concurrent (threadDelay)+import Control.Monad (void, (>=>))+import Data.Aeson (FromJSON (..), ToJSON (..))+import qualified Data.Either as Either+import Data.Foldable (traverse_)+import Data.IORef+import Data.Map (Map)+import qualified Data.Map as Map+import Data.Proxy (Proxy (..))+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Time.Clock+import GHC.Generics (Generic)++import Prod.Background (BackgroundVal)+import qualified Prod.Background as Background+import qualified Prod.Discovery as Discovery+import Prod.Health (GetReadinessApi, Readiness (..))+import Prod.Tracer (Tracer, contramap)+import qualified Prometheus as Prometheus++import Network.HTTP.Client (Manager, defaultManagerSettings, newManager)+import qualified Servant.Client as ServantClient++type Host = Text+type Port = Int+type Error = Text++data Track+ = HealthCheckStarted Host Port+ | HealthCheckFinished Host Port Check+ | BackgroundTrack Host Port (Background.Track CheckSummary)+ deriving (Show)++data Check+ = Success UTCTime Readiness+ | Failed UTCTime Error+ deriving (Show, Generic)+instance ToJSON Check+instance FromJSON Check++resultTime :: Check -> UTCTime+resultTime (Success t _) = t+resultTime (Failed t _) = t++isSuccess :: Check -> Bool+isSuccess (Success _ Ready) = True+isSuccess _ = False++getReadiness :: ServantClient.ClientM Readiness+getReadiness = ServantClient.client (Proxy @GetReadinessApi)++check :: Manager -> Host -> Port -> IO (Either Error Check)+check httpManager host port = do+ now <- getCurrentTime+ let env = ServantClient.mkClientEnv httpManager (ServantClient.BaseUrl ServantClient.Http (Text.unpack host) port "")+ r <- ServantClient.runClientM getReadiness env+ case r of+ Left err -> pure $ Left $ Text.pack $ show err+ Right v -> pure $ Right (Success now v)++data CheckSummary+ = CheckSummary+ { lastReady :: Maybe Check+ , recentChecks :: [Either Error Check]+ }+ deriving (Show, Generic)+instance ToJSON CheckSummary+instance FromJSON CheckSummary++-- | Predicate to tell if a Summary contains a long-enough check history to be considered.+healthChecked :: CheckSummary -> Bool+healthChecked c =+ length (recentChecks c) >= 3++-- | Predicate to tell if a Summary contains no recent successful healthcheck.+neverHealthy :: CheckSummary -> Bool+neverHealthy c =+ not $+ any isSuccess $+ Either.rights $+ recentChecks c++-- | Predicate to tell if the most recent summary exists and is successful.+recentlyHealthy :: CheckSummary -> Bool+recentlyHealthy c =+ maybe False isSuccess $+ safeHead $+ Either.rights $+ recentChecks c++emptyCheckSummary :: CheckSummary+emptyCheckSummary = CheckSummary Nothing []++updateSummary :: Either Error Check -> CheckSummary -> CheckSummary+updateSummary v@(Right c) s+ | isSuccess c = CheckSummary (Just c) (v : (take 2 (recentChecks s)))+ | otherwise = CheckSummary (lastReady s) (v : (take 2 (recentChecks s)))+updateSummary v@(Left _) s =+ CheckSummary (lastReady s) (v : (take 2 (recentChecks s)))++type CheckMap = Map (Host, Port) (BackgroundVal CheckSummary)++emptyCheckMap :: CheckMap+emptyCheckMap = Map.empty++initBackgroundCheck ::+ SpaceCounters ->+ Manager ->+ Tracer IO (Background.Track CheckSummary) ->+ (Host, Port) ->+ IO (BackgroundVal CheckSummary)+initBackgroundCheck cntrs manager tracer (h, p) =+ Background.background tracer emptyCheckSummary emptyCheckSummary step+ where+ step :: CheckSummary -> IO (CheckSummary, CheckSummary)+ step st0 = do+ ns_healthcheck_count cntrs $ Prometheus.incCounter+ res <- check manager h p+ threadDelay 5000000+ let st1 = updateSummary res st0+ pure (st1, st1)++terminateBackgroundCheck :: BackgroundVal CheckSummary -> IO ()+terminateBackgroundCheck = Background.kill++data Space+ = Space+ { spacehttpManager :: Manager+ , backgroundChecks :: IORef CheckMap+ , requestCheck :: (Host, Port) -> IO (BackgroundVal CheckSummary)+ , cancelCheck :: (Host, Port) -> IO ()+ }++clearSpace :: Space -> IO ()+clearSpace sp = do+ v <- atomicModifyIORef' (backgroundChecks sp) (\old -> (Map.empty, old))+ traverse_ terminateBackgroundCheck v++data Counters+ = Counters+ { healthcheck_added :: !(Prometheus.Vector Text Prometheus.Counter)+ , healthcheck_removed :: !(Prometheus.Vector Text Prometheus.Counter)+ , healthcheck_count :: !(Prometheus.Vector Text Prometheus.Counter)+ }++newCounters :: IO Counters+newCounters =+ Counters+ <$> counts "healthcheck_added"+ <*> counts "healthcheck_removed"+ <*> counts "healthchecks"+ where+ counts k =+ Prometheus.register $+ Prometheus.vector "ns" $+ Prometheus.counter (Prometheus.Info k "")++type WithSpaceCounter = (Prometheus.Counter -> IO ()) -> IO ()++data SpaceCounters+ = SpaceCounters+ { ns_healthcheck_added :: WithSpaceCounter+ , ns_healthcheck_removed :: WithSpaceCounter+ , ns_healthcheck_count :: WithSpaceCounter+ }++namespaceCounters :: Namespace -> Counters -> SpaceCounters+namespaceCounters ns cntrs =+ SpaceCounters+ (withNamespace (healthcheck_added cntrs))+ (withNamespace (healthcheck_removed cntrs))+ (withNamespace (healthcheck_count cntrs))+ where+ withNamespace v f = Prometheus.withLabel v ns f++initSpace :: SpaceCounters -> Manager -> Tracer IO Track -> IO Space+initSpace cntrs manager tracer = do+ r <- newIORef emptyCheckMap+ pure $ Space manager r (add cntrs r manager) (del cntrs r)+ where+ add :: SpaceCounters -> IORef CheckMap -> Manager -> (Host, Port) -> IO (BackgroundVal CheckSummary)+ add cntrs r manager = \hp -> do+ c <- Map.lookup hp <$> readIORef r+ case c of+ Nothing -> doadd cntrs r manager hp+ Just v -> pure v++ doadd :: SpaceCounters -> IORef CheckMap -> Manager -> (Host, Port) -> IO (BackgroundVal CheckSummary)+ doadd cntrs r manager = \hp@(h, p) -> do+ ns_healthcheck_added cntrs $ Prometheus.incCounter+ c <- initBackgroundCheck cntrs manager (contramap (BackgroundTrack h p) tracer) hp+ concurrentlyAdded <- atomicModifyIORef' r (\st0 -> (Map.insertWith (\_ old -> old) hp c st0, Map.lookup hp st0))+ case concurrentlyAdded of+ Nothing -> pure c+ Just leader -> terminateBackgroundCheck c >> pure leader++ del :: SpaceCounters -> IORef CheckMap -> (Host, Port) -> IO ()+ del cntrs r = \hp -> do+ print ("removing", hp)+ ns_healthcheck_removed cntrs $ Prometheus.incCounter+ c <- atomicModifyIORef' r (\st0 -> (Map.delete hp st0, Map.lookup hp st0))+ case c of+ Nothing -> pure ()+ Just b -> terminateBackgroundCheck b++setChecks :: Space -> [(Host, Port)] -> IO ()+setChecks space hps = do+ let wantedSet = Set.fromList hps+ currentSet <- Map.keysSet <$> readIORef (backgroundChecks space)+ let spurious = currentSet `Set.difference` wantedSet+ let missing = wantedSet `Set.difference` currentSet+ traverse_ (cancelCheck space) spurious+ traverse_ (requestCheck space) missing++cancelDeadChecks :: Space -> IO ()+cancelDeadChecks space = do+ summary <- readBackgroundChecks space+ traverse_ (cancelCheck space) (deadKeys summary)++{- | Helper to build a Tracer to update hosts to check based on DNS-discovered answers.+Note that the DNSTrack only gives Host, so you need to fmap the port.+-}+setChecksFromDNSDiscovery :: Space -> Discovery.DNSTrack [(Host, Port)] -> IO ()+setChecksFromDNSDiscovery space (Discovery.DNSTrack _ _ (Discovery.BackgroundTrack (Background.RunDone _ newDNSResult))) =+ case Discovery.toMaybe newDNSResult of+ Just xs -> traverse_ (requestCheck space) xs+ Nothing -> pure ()+setChecksFromDNSDiscovery hcrt _ = pure ()++{- | Same as 'setChecksFromDNSDiscovery' but only adding new checks.+You should clear checks of permanently invalid backends.+-}+addChecksFromDNSDiscovery :: Space -> Discovery.DNSTrack [(Host, Port)] -> IO ()+addChecksFromDNSDiscovery space (Discovery.DNSTrack _ _ (Discovery.BackgroundTrack (Background.RunDone _ newDNSResult))) =+ case Discovery.toMaybe newDNSResult of+ Just xs -> setChecks space xs+ Nothing -> pure ()+addChecksFromDNSDiscovery hcrt _ = pure ()++type SummaryMap = Map (Host, Port) (CheckSummary)++readCheckMap :: CheckMap -> IO SummaryMap+readCheckMap = traverse Background.readBackgroundVal++readBackgroundChecks :: Space -> IO SummaryMap+readBackgroundChecks = readIORef . backgroundChecks >=> readCheckMap++{- | Returns the set of (Host,Port) that are healthy in a given SummaryMap.++Healthiness consists in having the latest healthcheck as healthy.+-}+healthyKeys :: SummaryMap -> [(Host, Port)]+healthyKeys m =+ fmap fst $+ filter (recentlyHealthy . snd) $+ Map.toList m++{- | Returns the set of (Host,Port) that have no recent successful activity+provided there is enough health-checking history.+-}+deadKeys :: SummaryMap -> [(Host, Port)]+deadKeys m =+ fmap fst $+ filter (\(_, x) -> neverHealthy x && healthChecked x) $+ Map.toList m++safeHead :: [a] -> Maybe a+safeHead (x : _) = Just x+safeHead _ = Nothing++type Namespace = Text++type Namespaced a = (Namespace, a)++data Runtime+ = Runtime+ { counters :: Counters+ , httpManager :: Manager+ , tracer :: Tracer IO (Namespaced Track)+ , -- todo: split globals env values and dynamic-space storage+ spaces :: IORef (Map Namespace Space)+ }++initRuntime :: Tracer IO (Namespaced Track) -> IO Runtime+initRuntime tracer = do+ r <- newIORef Map.empty+ manager <- newManager defaultManagerSettings+ cntrs <- newCounters+ pure $ Runtime cntrs manager tracer r++readSpaces :: Runtime -> IO (Map Namespace SummaryMap)+readSpaces rt = do+ r <- readIORef . spaces $ rt+ traverse readBackgroundChecks r++registerSpace :: Runtime -> Namespace -> IO Space+registerSpace rt ns = withSpace rt ns pure++withSpace :: Runtime -> Namespace -> (Space -> IO a) -> IO a+withSpace rt ns run = do+ let r = spaces rt+ sp <- Map.lookup ns <$> readIORef r+ case sp of+ Just s -> run s+ Nothing -> do+ s <- initRuntimeSpace rt ns+ concurrentlyAdded <- atomicModifyIORef' r (\st0 -> (Map.insertWith (\_ old -> old) ns s st0, Map.lookup ns st0))+ case concurrentlyAdded of+ Nothing -> run s+ Just leader -> clearSpace s >> run leader++-- | Only create a space (no registration).+initRuntimeSpace :: Runtime -> Namespace -> IO Space+initRuntimeSpace rt ns =+ initSpace+ (namespaceCounters ns $ counters rt)+ (httpManager rt)+ (contramap (\x -> (ns, x)) (tracer rt))
+ src/Prod/MimeTypes.hs view
@@ -0,0 +1,41 @@+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}++module Prod.MimeTypes where++import Data.ByteString (ByteString)+import Data.ByteString.Lazy (fromStrict)+import qualified Network.HTTP.Media as M+import Servant+import System.IO.Unsafe++data HTML++data PNG++data SVG++data GraphPictureData+ = GraphPictureData+ { graphvizInput :: !ByteString+ , serializedPng :: (IO ByteString)+ , serializedSvg :: (IO ByteString)+ }++instance Accept HTML where+ contentType _ = "text" M.// "html" M./: ("charset", "utf-8")++instance Accept PNG where+ contentType _ = "image" M.// "png"++instance MimeRender PNG GraphPictureData where+ mimeRender _ = fromStrict . unsafePerformIO . serializedPng++instance MimeRender PlainText GraphPictureData where+ mimeRender _ = fromStrict . graphvizInput++instance Accept SVG where+ contentType _ = "image" M.// "svg"++instance MimeRender SVG GraphPictureData where+ mimeRender _ = fromStrict . unsafePerformIO . serializedSvg
+ src/Prod/Prometheus.hs view
@@ -0,0 +1,82 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE MultiParamTypeClasses #-}++module Prod.Prometheus (+ handlePrometheus,+ PrometheusApi,+ CORSAllowOrigin (..),+ PrometheusResult (..),+ initPrometheus,+ inc,+ obs,+ timeIt,+)+where++import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.ByteString.Lazy (ByteString)+import Data.Coerce (coerce)+import Data.Text (Text)+import Data.Time.Clock (diffUTCTime, getCurrentTime)+import qualified Network.HTTP.Media as M+import Prometheus (Counter, Vector, exportMetricsAsText, register)+import qualified Prometheus as Prometheus+import Prometheus.Metric.GHC (GHCMetrics, ghcMetrics)+import Servant+import Servant.Server (Handler, Server)++newtype PrometheusResult = PrometheusResult {toLBS :: ByteString}++instance MimeRender PlainText PrometheusResult where+ mimeRender _ = toLBS++type PrometheusApi =+ Summary "Prometheus metrics"+ :> "metrics"+ :> Get '[PlainText] (Headers '[Header "Access-Control-Allow-Origin" CORSAllowOrigin] PrometheusResult)++newtype CORSAllowOrigin = CORSAllowOrigin Text+ deriving (ToHttpApiData)++handlePrometheus :: CORSAllowOrigin -> Server PrometheusApi+handlePrometheus corsAllow = handleMetrics+ where+ handleMetrics :: Handler (Headers '[Header "Access-Control-Allow-Origin" CORSAllowOrigin] PrometheusResult)+ handleMetrics = do+ metrics <- liftIO $ exportMetricsAsText+ pure $ addHeader (coerce corsAllow) $ PrometheusResult metrics++initPrometheus :: IO GHCMetrics+initPrometheus = register ghcMetrics++inc ::+ (MonadIO m) =>+ (a -> Vector Text Counter) ->+ Text ->+ a ->+ m ()+inc f s cnts =+ liftIO $ Prometheus.withLabel (f cnts) s Prometheus.incCounter++obs ::+ (MonadIO m) =>+ (a -> Prometheus.Summary) ->+ Double ->+ a ->+ m ()+obs f v cnts =+ liftIO $ Prometheus.observe (f cnts) v++timeIt ::+ (MonadIO m) =>+ (a -> Prometheus.Summary) ->+ a ->+ m b ->+ m b+timeIt f cnts action = do+ t0 <- liftIO $ getCurrentTime+ !ret <- action+ t1 <- liftIO $ getCurrentTime+ obs f (fromRational $ toRational $ diffUTCTime t1 t0) cnts+ pure ret
+ src/Prod/Reports.hs view
@@ -0,0 +1,78 @@+{-# LANGUAGE KindSignatures #-}++module Prod.Reports (+ ReportsApi,+ Report (..),+ countReports,+ Runtime,+ initRuntime,+)+where++import Control.Monad.IO.Class (liftIO)+import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.=))+import GHC.TypeLits (Symbol)+import Prod.Tracer+import qualified Prometheus as Prometheus+import Servant (JSON, Post, ReqBody, Summary, (:>))+import Servant.Server (Handler)++{- | Some minimal report wrapper.+Has low expectations on the client.+-}+data Report a = Report {posixTime :: Int, backoff :: Int, events :: [a]}++instance (FromJSON a) => FromJSON (Report a) where+ parseJSON = withObject "Report" $ \o ->+ Report <$> o .: "t" <*> o .: "b" <*> o .: "es"+instance (ToJSON a) => ToJSON (Report a) where+ toJSON (Report t b es) = object ["t" .= t, "b" .= b, "es" .= es]++type ReportsApi a =+ Summary "receives and acknowledge some reports"+ :> "reports"+ :> ReqBody '[JSON] (Report a)+ :> Post '[JSON] Int++-- | A set of counters tracking .+data Counters+ = Counters+ { reports_count :: !Prometheus.Counter+ , reported_events :: !Prometheus.Counter+ , reported_sizes :: !Prometheus.Summary+ , reported_backoffs :: !Prometheus.Summary+ }++newCounters :: IO Counters+newCounters =+ Counters+ <$> counts "reports"+ <*> counts "report_events"+ <*> summary "report_sizes"+ <*> summary "report_backoffs"+ where+ counts k =+ Prometheus.register $+ Prometheus.counter (Prometheus.Info k "")+ summary k =+ Prometheus.register $+ Prometheus.summary (Prometheus.Info k "") Prometheus.defaultQuantiles++-- | A default runtime for the `dropReports` route.+data Runtime a = Runtime {counters :: !Counters, tracer :: !(Tracer IO (Report a))}++initRuntime :: Tracer IO (Report a) -> IO (Runtime a)+initRuntime tracer = Runtime <$> newCounters <*> pure tracer++-- | Count and drop reports.+countReports :: Runtime a -> Report a -> Handler Int+countReports (Runtime counters tracer) report = do+ let size = length (events report)+ let dsize = fromIntegral size+ liftIO $ do+ runTracer tracer $ report+ Prometheus.incCounter (reports_count counters)+ Prometheus.addCounter (reported_events counters) dsize+ Prometheus.observe (reported_sizes counters) dsize+ Prometheus.observe (reported_backoffs counters) (fromIntegral $ backoff report)+ pure size
+ src/Prod/Status.hs view
@@ -0,0 +1,148 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DerivingVia #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StandaloneDeriving #-}++module Prod.Status (+ StatusApi,+ RenderStatus,+ defaultStatusPage,+ metricsSection,+ versionsSection,+ statusPage,+ handleStatus,+ Status (..),+ Identification (..),+ this,+)+where++import Control.Monad.IO.Class (liftIO)+import Data.Aeson (ToJSON (..), (.=))+import qualified Data.Aeson as Aeson+import Data.Foldable (traverse_)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.UUID (UUID)+import Data.UUID.V4 (nextRandom)+import Data.Version (Version, showVersion)+import GHC.Generics (Generic)+import Prod.Health as Health+import Prod.MimeTypes (HTML)+import Servant (Get, JSON, MimeRender (..), (:>))+import Servant.Server (Handler)+import System.IO.Unsafe (unsafePerformIO)++import Lucid++type StatusApi a =+ "status"+ :> Get '[HTML, JSON] (Status a)++newtype Identification = Identification Text+ deriving+ (ToJSON)+ via Text++-- | Type to render a status page.+type RenderStatus a = Status a -> Html ()++data Status a+ = Status+ { identification :: !Identification+ , liveness :: !Liveness+ , readiness :: !Readiness+ , appStatus :: !a+ , renderer :: RenderStatus a+ }++instance (ToJSON a) => ToJSON (Status a) where+ toJSON (Status i l r st _) =+ Aeson.object+ [ "id" .= i+ , "liveness" .= l+ , "readiness" .= r+ , "status" .= toJSON st+ ]++handleStatus :: Runtime -> IO a -> RenderStatus a -> Handler (Status a)+handleStatus runtime getAppStatus render =+ liftIO $+ Status this+ <$> Health.liveness runtime+ <*> Health.completeReadiness runtime+ <*> getAppStatus+ <*> (pure render)++{-# NOINLINE this #-}+this :: Identification+this = unsafePerformIO $ fmap (Identification . Text.pack . show) nextRandom++instance {-# OVERLAPPABLE #-} MimeRender HTML (Status a) where+ mimeRender _ st = renderBS $ render st+ where+ render = renderer st++defaultStatusPage :: forall a. (a -> Html ()) -> RenderStatus a+defaultStatusPage renderAppStatus = go+ where+ go :: Status a -> Html ()+ go (Status (Identification uuid) liveness readiness appStatus _) =+ html_ $ do+ head_ $ do+ title_ "status page"+ link_ [rel_ "stylesheet", type_ "text/css", href_ "status.css"]+ body_ $ do+ section_ $ do+ h1_ "identification"+ p_ $ toHtml uuid+ section_ $ do+ h1_ "general status"+ renderLiveness liveness+ renderReadiness readiness+ with form_ [action_ "/health/drain", method_ "post"] $+ input_ [type_ "submit", value_ "drain me"]+ section_ $ do+ h1_ "app status"+ renderAppStatus appStatus+ renderLiveness :: Liveness -> Html ()+ renderLiveness Alive = p_ $ with a_ [href_ "/health/alive"] "alive"++ renderReadiness :: Readiness -> Html ()+ renderReadiness Ready = p_ $ with a_ [href_ "/health/ready"] "ready"+ renderReadiness (Ill reasons) = do+ p_ $ with a_ [href_ "/health/ready"] "not-ready"+ ul_ $ do+ traverse_ renderReason reasons++ renderReason :: Reason -> Html ()+ renderReason (Reason r) =+ li_ $ toHtml r++{- | Like defaultStatusPage but uses a type-class-defined to pass the+application-status rendering.+-}+statusPage :: (ToHtml a) => RenderStatus a+statusPage = defaultStatusPage toHtml++type MetricsJSurl = Text++-- | Section with metrics.+metricsSection :: MetricsJSurl -> RenderStatus a+metricsSection metrics_js = const $+ section_ $ do+ h1_ "metrics"+ with div_ [id_ "metrics"] $ pure ()+ toHtmlRaw @Text $ "<script async type=\"text/javascript\" src=\"" <> metrics_js <> "\"></script>"++versionsSection :: [(String, Version)] -> RenderStatus a+versionsSection pkgs = const $+ section_ $ do+ h1_ "versions"+ ul_ $+ traverse_ renderVersion pkgs+ where+ renderVersion :: (String, Version) -> Html ()+ renderVersion (pkg, ver) =+ li_ $ p_ $ toHtml $ pkg <> ":" <> showVersion ver
+ src/Prod/Tracer.hs view
@@ -0,0 +1,112 @@+-- https://www.youtube.com/watch?v=qzOQOmmkKEM&feature=emb_logo++module Prod.Tracer (+ Tracer (..),+ silent,+ traceIf,+ traceBoth,++ -- * common utilities+ tracePrint,+ traceHPrint,+ traceHPut,+ encodeJSON,+ pulls,++ -- * re-exports+ Contravariant (..),+ Divisible (..),+ Decidable (..),+) where++import Control.Monad ((>=>))+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Aeson (ToJSON, encode)+import Data.ByteString.Lazy (ByteString, hPut)+import Data.Functor.Contravariant+import Data.Functor.Contravariant.Divisible+import System.IO (Handle, hPrint)++newtype Tracer m a = Tracer {runTracer :: (a -> m ())}++instance Contravariant (Tracer m) where+ contramap f (Tracer g) = Tracer (g . f)++instance (Applicative m) => Divisible (Tracer m) where+ conquer = silent+ divide = traceSplit++instance (Applicative m) => Decidable (Tracer m) where+ lose _ = silent+ choose = tracePick++-- | Disable Tracing.+{-# INLINE silent #-}+silent :: (Applicative m) => Tracer m a+silent = Tracer (const $ pure ())++{- | Splits a tracer into two chunks that are run sequentially.++This name can be confusing but it has to be thought backwards for Contravariant logging:+We compose a target tracer from two tracers but we split the content of the trace.++Note that the split function may actually duplicate inputs (that's how traceBoth works).+-}+{-# INLINEABLE traceSplit #-}+traceSplit :: (Applicative m) => (c -> (a, b)) -> Tracer m a -> Tracer m b -> Tracer m c+traceSplit split (Tracer f1) (Tracer f2) = Tracer (go . split)+ where+ go (b, c) = f1 b *> f2 c++{- | If you are given two tracers and want to pass both.+Composition occurs in sequence.+-}+{-# INLINEABLE traceBoth #-}+traceBoth :: (Applicative m) => Tracer m a -> Tracer m a -> Tracer m a+traceBoth t1 t2 = traceSplit (\x -> (x, x)) t1 t2++{- | Picks a tracer based on the emitted object.+Example logic that can be built is traceIf that silent messages.+-}+{-# INLINEABLE tracePick #-}+tracePick :: (Applicative m) => (c -> Either a b) -> Tracer m a -> Tracer m b -> Tracer m c+tracePick split (Tracer f1) (Tracer f2) = Tracer $ \a ->+ let e = split a+ in either f1 f2 e++-- | Filter by dynamically testing values.+{-# INLINEABLE traceIf #-}+traceIf :: (Applicative m) => (a -> Bool) -> Tracer m a -> Tracer m a+traceIf predicate t = tracePick (\x -> if predicate x then Left () else Right x) silent t++-- | A tracer that prints emitted events.+tracePrint :: (MonadIO m, Show a) => Tracer m a+tracePrint = Tracer (liftIO . print)++-- | A tracer that prints emitted to some handle.+traceHPrint :: (MonadIO m, Show a) => Handle -> Tracer m a+traceHPrint handle = Tracer (liftIO . hPrint handle)++-- | A tracer that puts some ByteString to some handle.+traceHPut :: (MonadIO m) => Handle -> Tracer m ByteString+traceHPut handle = Tracer (liftIO . hPut handle)++-- | A conversion encoding values to JSON.+{-# INLINE encodeJSON #-}+encodeJSON :: (ToJSON a) => Tracer m ByteString -> Tracer m a+encodeJSON = contramap encode++{- | Pulls a value to complete a trace when a trace occurs.++This function allows to combines pushed values with pulled values. Hence,+performing some scheduling between behaviours.+Typical usage would be to annotate a trace with a background value, or perform+data augmentation in a pipelines of traces.++Note that if you rely on this function you need to pay attention of the+blocking effect of 'pulls': the traced value c is not forwarded until a+value b is available.+-}+{-# INLINE pulls #-}+pulls :: (Monad m) => (c -> m b) -> Tracer m b -> Tracer m c+pulls act (Tracer f1) = Tracer $ act >=> f1
+ src/Prod/Watchdog.hs view
@@ -0,0 +1,93 @@+module Prod.Watchdog where++import Control.Exception.Base (IOException, catch)+import Control.Monad (when)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Time.Clock (UTCTime, getCurrentTime)+import Prod.Background (BackgroundVal, MicroSeconds, backgroundLoop)+import qualified Prod.Background+import Prod.Tracer (Tracer (..), contramap)+import Prometheus (Counter, Label1, Label2, Vector)+import qualified Prometheus as Prometheus+import System.Directory (doesFileExist, setModificationTime)++data Track r = BackgroundTrack (Prod.Background.Track (WatchdogResult r))+ deriving (Show)++data WatchdogResult a+ = Skipped+ | Success a+ | Failed+ deriving (Show, Ord, Eq)++data Watchdog a = Watchdog+ { backgroundVal :: BackgroundVal (WatchdogResult a)+ , tracer :: Tracer IO (Track a)+ }++watchdog ::+ (Prometheus.Label label) =>+ Vector label Counter ->+ Tracer IO (Track a) ->+ (WatchdogResult a -> label) ->+ MicroSeconds Int ->+ IO (WatchdogResult a) ->+ IO (Watchdog a)+watchdog counters tracer mkLabel delay action =+ Watchdog <$> backgroundLoop (contramap BackgroundTrack tracer) Skipped go delay <*> pure tracer+ where+ go = do+ res <- action+ Prometheus.withLabel counters (mkLabel res) Prometheus.incCounter+ pure res++{- | Basic watchdog with a vector metric.+The input vector label is set with success|failed|skipped depending on the WatchdogResult.+-}+basicWatchdog ::+ Vector Label1 Counter ->+ Tracer IO (Track a) ->+ MicroSeconds Int ->+ IO (WatchdogResult a) ->+ IO (Watchdog a)+basicWatchdog counters tracer delay action =+ watchdog counters tracer basicLabel delay action++basicLabel :: WatchdogResult a -> Label1+basicLabel res = case res of+ Success _ -> "success"+ Failed -> "failed"+ Skipped -> "skipped"++data FileTouchTrack r = FileTouchTrack FilePath (Track r)+ deriving (Show)++{- | Touches a file periodically, using setModificationTime.+If the file does not exists when the watchdog is initialized, then it is+created empty.+-}+fileTouchWatchdog ::+ FilePath ->+ Tracer IO (FileTouchTrack UTCTime) ->+ MicroSeconds Int ->+ IO (Watchdog UTCTime)+fileTouchWatchdog path tracer delay = do+ let mkLabel res = (basicLabel res, Text.pack path)+ shouldCreate <- not <$> doesFileExist path+ when shouldCreate $ writeFile path ""+ watchdog fileTouchWatchdogCounter (contramap (FileTouchTrack path) tracer) mkLabel delay io+ where+ handleIOException :: IOException -> IO (WatchdogResult UTCTime)+ handleIOException _ = pure $ Failed+ io = do+ now <- getCurrentTime+ let touchFile = setModificationTime path now *> pure (Success now)+ touchFile `catch` handleIOException++{-# NOINLINE fileTouchWatchdogCounter #-}+fileTouchWatchdogCounter :: Vector Label2 Counter+fileTouchWatchdogCounter =+ Prometheus.unsafeRegister $+ Prometheus.vector ("status", "path") $+ Prometheus.counter (Prometheus.Info "prodapi_watchdog_filetouch" "")