diff --git a/PaymentServer.cabal b/PaymentServer.cabal index ad233b8d51579539b5ee8146920761a97681178a..134ce45a529fc52c21f8806b3bc2299dd34f5ea6 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -20,6 +20,7 @@ library , PaymentServer.Issuer , PaymentServer.Persistence , PaymentServer.Redemption + , PaymentServer.Metrics , PaymentServer.Server , PaymentServer.Main build-depends: base >= 4.7 && < 5 @@ -43,6 +44,8 @@ library , cryptonite , sqlite-simple , retry + , prometheus-client + , servant-prometheus default-language: Haskell2010 ghc-options: -Wmissing-import-lists -Wunused-imports pkgconfig-depends: libchallenge_bypass_ristretto_ffi @@ -69,13 +72,20 @@ test-suite PaymentServer-tests hs-source-dirs: test main-is: Spec.hs other-modules: Persistence + , Metrics build-depends: base + , bytestring , text , tasty , tasty-hunit , directory , async , sqlite-simple + , http-types + , wai + , wai-extra + , servant-server + , prometheus-client , PaymentServer default-language: Haskell2010 diff --git a/nix/PaymentServer.nix b/nix/PaymentServer.nix index af085ed41911cae1c49807f6b15ab00d013372dd..846f1c154ce298dbd260aace8830642e29e17484 100644 --- a/nix/PaymentServer.nix +++ b/nix/PaymentServer.nix @@ -77,6 +77,8 @@ in { system, compiler, flags, pkgs, hsPkgs, pkgconfPkgs, ... }: (hsPkgs."cryptonite" or (buildDepError "cryptonite")) (hsPkgs."sqlite-simple" or (buildDepError "sqlite-simple")) (hsPkgs."retry" or (buildDepError "retry")) + (hsPkgs."prometheus-client" or (buildDepError "prometheus-client")) + (hsPkgs."servant-prometheus" or (buildDepError "servant-prometheus")) ]; pkgconfig = [ (pkgconfPkgs."libchallenge_bypass_ristretto_ffi" or (pkgConfDepError "libchallenge_bypass_ristretto_ffi")) diff --git a/nix/pkgs.nix b/nix/pkgs.nix index 8997dd1f9c75453787358677081b314ae4262e3a..24d76f5e99175d041360587d5739c7e23a509211 100644 --- a/nix/pkgs.nix +++ b/nix/pkgs.nix @@ -5,7 +5,10 @@ "stripe-core" = (((hackage.stripe-core)."2.5.0").revisions).default; "stripe-haskell" = (((hackage.stripe-haskell)."2.5.0").revisions).default; "stripe-http-client" = (((hackage.stripe-http-client)."2.5.0").revisions).default; - } // { PaymentServer = ./PaymentServer.nix; }) // {}; + } // { + PaymentServer = ./PaymentServer.nix; + servant-prometheus = ./servant-prometheus.nix; + }) // {}; }; resolver = "lts-14.1"; } \ No newline at end of file diff --git a/nix/servant-prometheus.nix b/nix/servant-prometheus.nix new file mode 100644 index 0000000000000000000000000000000000000000..fb013289ac659a8bad420e5ef170d2a26517febe --- /dev/null +++ b/nix/servant-prometheus.nix @@ -0,0 +1,112 @@ +let + buildDepError = pkg: + builtins.throw '' + The Haskell package set does not contain the package: ${pkg} (build dependency). + + If you are using Stackage, make sure that you are using a snapshot that contains the package. Otherwise you may need to update the Hackage snapshot you are using, usually by updating haskell.nix. + ''; + sysDepError = pkg: + builtins.throw '' + The Nixpkgs package set does not contain the package: ${pkg} (system dependency). + + You may need to augment the system package mapping in haskell.nix so that it can be found. + ''; + pkgConfDepError = pkg: + builtins.throw '' + The pkg-conf packages does not contain the package: ${pkg} (pkg-conf dependency). + + You may need to augment the pkg-conf package mapping in haskell.nix so that it can be found. + ''; + exeDepError = pkg: + builtins.throw '' + The local executable components do not include the component: ${pkg} (executable dependency). + ''; + legacyExeDepError = pkg: + builtins.throw '' + The Haskell package set does not contain the package: ${pkg} (executable dependency). + + If you are using Stackage, make sure that you are using a snapshot that contains the package. Otherwise you may need to update the Hackage snapshot you are using, usually by updating haskell.nix. + ''; + buildToolDepError = pkg: + builtins.throw '' + Neither the Haskell package set or the Nixpkgs package set contain the package: ${pkg} (build tool dependency). + + If this is a system dependency: + You may need to augment the system package mapping in haskell.nix so that it can be found. + + If this is a Haskell dependency: + If you are using Stackage, make sure that you are using a snapshot that contains the package. Otherwise you may need to update the Hackage snapshot you are using, usually by updating haskell.nix. + ''; +in { system, compiler, flags, pkgs, hsPkgs, pkgconfPkgs, ... }: + { + flags = {}; + package = { + specVersion = "1.10"; + identifier = { name = "servant-prometheus"; version = "0.1.0.0"; }; + license = "BSD-3-Clause"; + copyright = ""; + maintainer = "Alex Mason <axman6@gmail.com>, Jack Kelly <jack.kelly@data61.csiro.au>"; + author = "Alex Mason <axman6@gmail.com>, Anchor Engineering <engineering@lists.anchor.net.au>, Servant Contributors"; + homepage = ""; + url = ""; + synopsis = "Helpers for using prometheus with servant"; + description = "Helpers for using prometheus with servant. Each endpoint has its own metrics allowing more detailed monitoring than wai-middleware-prometheus allows"; + buildType = "Simple"; + }; + components = { + "library" = { + depends = [ + (hsPkgs."base" or (buildDepError "base")) + (hsPkgs."prometheus-client" or (buildDepError "prometheus-client")) + (hsPkgs."servant" or (buildDepError "servant")) + (hsPkgs."http-types" or (buildDepError "http-types")) + (hsPkgs."text" or (buildDepError "text")) + (hsPkgs."time" or (buildDepError "time")) + (hsPkgs."unordered-containers" or (buildDepError "unordered-containers")) + (hsPkgs."wai" or (buildDepError "wai")) + (hsPkgs."bytestring" or (buildDepError "bytestring")) + ]; + }; + exes = { + "bench" = { + depends = [ + (hsPkgs."base" or (buildDepError "base")) + (hsPkgs."aeson" or (buildDepError "aeson")) + (hsPkgs."servant-prometheus" or (buildDepError "servant-prometheus")) + (hsPkgs."servant-server" or (buildDepError "servant-server")) + (hsPkgs."prometheus-client" or (buildDepError "prometheus-client")) + (hsPkgs."text" or (buildDepError "text")) + (hsPkgs."wai" or (buildDepError "wai")) + (hsPkgs."warp" or (buildDepError "warp")) + (hsPkgs."process" or (buildDepError "process")) + ]; + }; + }; + tests = { + "spec" = { + depends = [ + (hsPkgs."base" or (buildDepError "base")) + (hsPkgs."aeson" or (buildDepError "aeson")) + (hsPkgs."servant-prometheus" or (buildDepError "servant-prometheus")) + (hsPkgs."servant-server" or (buildDepError "servant-server")) + (hsPkgs."servant-client" or (buildDepError "servant-client")) + (hsPkgs."servant" or (buildDepError "servant")) + (hsPkgs."prometheus-client" or (buildDepError "prometheus-client")) + (hsPkgs."http-client" or (buildDepError "http-client")) + (hsPkgs."text" or (buildDepError "text")) + (hsPkgs."wai" or (buildDepError "wai")) + (hsPkgs."warp" or (buildDepError "warp")) + (hsPkgs."hspec" or (buildDepError "hspec")) + (hsPkgs."unordered-containers" or (buildDepError "unordered-containers")) + (hsPkgs."transformers" or (buildDepError "transformers")) + ]; + }; + }; + }; + } // { + src = (pkgs.lib).mkDefault (pkgs.fetchgit { + url = "https://github.com/PrivateStorageio/servant-prometheus.git"; + rev = "ec21c5ed50e6f6f8e52916ce71cd68fcd0166cad"; + sha256 = "0lswszfs52x5rpf7lj46iv77zghcbr4d05dwssi63yzjll1ixizd"; + }); + } \ No newline at end of file diff --git a/src/PaymentServer/Main.hs b/src/PaymentServer/Main.hs index f32512abaaeee4eaa4ceeba6623de581aed2bf16..e7d700cfee97dedebb44e8c1f14371d64781f8a3 100644 --- a/src/PaymentServer/Main.hs +++ b/src/PaymentServer/Main.hs @@ -72,6 +72,7 @@ import PaymentServer.Issuer ) import PaymentServer.Server ( paymentServerApp + , makeMetricsMiddleware ) import Options.Applicative @@ -313,5 +314,6 @@ getApp config = let origins = corsOrigins config app = paymentServerApp origins stripeConfig' issuer db + metricsMiddleware <- makeMetricsMiddleware logger <- mkRequestLogger (def { outputFormat = Detailed True}) - return $ logger app + return . logger . metricsMiddleware $ app diff --git a/src/PaymentServer/Metrics.hs b/src/PaymentServer/Metrics.hs new file mode 100644 index 0000000000000000000000000000000000000000..92879f8d9a33b9fa6824f29509158b6c40f99f00 --- /dev/null +++ b/src/PaymentServer/Metrics.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeOperators #-} + +-- | A module which provides Prometheus metrics publishing. Individual +-- metrics are defined elsewhere in the codebase but they'll all be published +-- by this module. + +module PaymentServer.Metrics + ( MetricsAPI + , metricsAPI + , metricsServer + ) where + +import Data.Text.Lazy + ( Text + ) +import Data.Text.Lazy.Encoding + ( decodeUtf8 + ) + +import Prometheus + ( exportMetricsAsText + ) + +import Servant + ( Proxy(Proxy) + , Server + , Handler + , Get + , PlainText + , (:>) + ) + +type MetricsAPI = "metrics" :> Get '[PlainText] Text + +metricsAPI :: Proxy MetricsAPI +metricsAPI = Proxy + +metricsServer :: Server MetricsAPI +metricsServer = metrics + +metrics :: Handler Text +metrics = exportMetricsAsText >>= return . decodeUtf8 diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 8506c6b697b2981627fee02edefceca617f2653b..45b5af38613cc4cafff878ba2a4ae79e2097ff2d 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -18,14 +18,10 @@ import Control.Exception ( try , throwIO ) -import Data.ByteString - ( ByteString - ) import Data.Text ( Text , unpack ) -import qualified Data.Map as Map import Text.Read ( readMaybe ) @@ -41,8 +37,6 @@ import Data.Aeson import Servant ( Server , Handler - , err400 - , err500 , ServerError(ServerError, errHTTPCode, errBody, errHeaders, errReasonPhrase) , throwError ) @@ -51,22 +45,12 @@ import Servant.API , JSON , Post , (:>) - , (:<|>)((:<|>)) - ) -import Web.Stripe.Event - ( Event(Event, eventId, eventType, eventData) - , EventId(EventId) - , EventType(ChargeSucceededEvent) - , EventData(ChargeEvent) ) import Web.Stripe.Types ( Charge(Charge, chargeMetaData) , MetaData(MetaData) , Currency ) -import Web.Stripe.Error - ( StripeError(StripeError) - ) import Web.Stripe.Charge ( createCharge , Amount(Amount) @@ -79,6 +63,9 @@ import Web.Stripe ( stripe , (-&-) ) + +import qualified Prometheus as P + import PaymentServer.Persistence ( Voucher , VoucherDatabase(payForVoucher) @@ -102,7 +89,8 @@ getVoucher (MetaData (("Voucher", value):xs)) = Just value getVoucher (MetaData (x:xs)) = getVoucher (MetaData xs) stripeServer :: VoucherDatabase d => StripeConfig -> d -> Server StripeAPI -stripeServer stripeConfig d = charge d stripeConfig +stripeServer stripeConfig d = + withSuccessFailureMetrics chargeAttempts chargeSuccesses . charge stripeConfig d -- | Browser facing API that takes token, voucher and a few other information -- and calls stripe charges API. If payment succeeds, then the voucher is stored @@ -126,10 +114,40 @@ instance FromJSON Charges where v .: "currency" parseJSON _ = mzero + +metricName :: Text -> Text +metricName name = mappend ("processors.stripe.charge_") name + +chargeAttempts :: P.Counter +chargeAttempts + = P.unsafeRegister + $ P.counter + $ P.Info (metricName "attempts") + "The number of attempted charge requests received." + + +chargeSuccesses :: P.Counter +chargeSuccesses + = P.unsafeRegister + $ P.counter + $ P.Info (metricName "successes") + "The number of charge requests successfully processed." + + +-- | run a Servant Handler, recording the attempt and whether or not it +-- succeeds using the given counters. +withSuccessFailureMetrics :: P.Counter -> P.Counter -> Handler a -> Handler a +withSuccessFailureMetrics attemptCount successCount op = do + liftIO $ P.incCounter attemptCount + result <- op + liftIO $ P.incCounter successCount + return result + + -- | call the stripe Charge API (with token, voucher in metadata, amount, currency etc -- and if the Charge is okay, then set the voucher as "paid" in the database. -charge :: VoucherDatabase d => d -> StripeConfig -> Charges -> Handler Acknowledgement -charge d stripeConfig (Charges token voucher amount currency) = do +charge :: VoucherDatabase d => StripeConfig -> d -> Charges -> Handler Acknowledgement +charge stripeConfig d (Charges token voucher amount currency) = do currency' <- getCurrency currency result <- liftIO (try (payForVoucher d voucher (completeStripeCharge currency'))) case result of diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs index a789f38a2e6baa1969c3643e82bc6f7ff0500020..8842dab1ee84f211897886dfbc181dcd12c09a4e 100644 --- a/src/PaymentServer/Server.hs +++ b/src/PaymentServer/Server.hs @@ -6,6 +6,7 @@ -- interactions. module PaymentServer.Server ( paymentServerApp + , makeMetricsMiddleware ) where import Network.Wai.Middleware.Cors @@ -22,6 +23,11 @@ import Servant , (:>) , (:<|>)((:<|>)) ) +import Servant.Prometheus + ( MeasureQuantiles(WithQuantiles) + , monitorServant + , makeMeters + ) import Web.Stripe.Client ( StripeConfig @@ -35,6 +41,10 @@ import PaymentServer.Redemption ( RedemptionAPI , redemptionServer ) +import PaymentServer.Metrics + ( MetricsAPI + , metricsServer + ) import PaymentServer.Issuer ( Issuer ) @@ -46,12 +56,14 @@ import PaymentServer.Persistence type PaymentServerAPI = "v1" :> "stripe" :> StripeAPI :<|> "v1" :> "redeem" :> RedemptionAPI + :<|> MetricsAPI -- | Create a server which uses the given database. paymentServer :: VoucherDatabase d => StripeConfig -> Issuer -> d -> Server PaymentServerAPI paymentServer stripeConfig issuer database = stripeServer stripeConfig database :<|> redemptionServer issuer database + :<|> metricsServer paymentServerAPI :: Proxy PaymentServerAPI paymentServerAPI = Proxy @@ -77,3 +89,10 @@ paymentServerApp corsOrigins stripeConfig issuer = cors' = cors (const $ Just corsResourcePolicy) in cors' . app + + +-- | Create middleware which captures metrics for the payment server app. +makeMetricsMiddleware :: IO (Application -> Application) +makeMetricsMiddleware = do + meters <- makeMeters paymentServerAPI WithQuantiles + return $ monitorServant paymentServerAPI meters diff --git a/stack.yaml b/stack.yaml index 0af84a35031953cbffd353476578ffa00b37b01f..3a4243691337cedb0356d9a76154c8aef27c46bd 100644 --- a/stack.yaml +++ b/stack.yaml @@ -41,6 +41,8 @@ extra-deps: - "stripe-core-2.5.0" - "stripe-haskell-2.5.0" - "stripe-http-client-2.5.0" + - github: "PrivateStorageio/servant-prometheus" + commit: "ec21c5ed50e6f6f8e52916ce71cd68fcd0166cad" # Override default flag values for local packages and extra-deps # flags: {} diff --git a/test/Metrics.hs b/test/Metrics.hs new file mode 100644 index 0000000000000000000000000000000000000000..54eca21d6760afba4d65108af98511eab0d0561d --- /dev/null +++ b/test/Metrics.hs @@ -0,0 +1,110 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | Tests related to PaymentServer.Metrics and the metrics it exposes. + +module Metrics + ( tests + ) where + +import Data.ByteString.Lazy.Char8 + ( pack + ) + +import Test.Tasty + ( TestTree + , testGroup + ) + +import Test.Tasty.HUnit + ( testCase + , assertEqual + ) + +import Network.HTTP.Types + ( methodGet + ) +import Network.Wai + ( defaultRequest + , requestMethod + ) +import Network.Wai.Test + ( Session + , SResponse + , runSession + , setPath + , request + , assertStatus + , assertContentType + , assertBody + ) + +import Servant + ( Application + , serve + ) + +import Prometheus + ( Info(Info) + , unsafeRegister + , counter + , incCounter + ) + +import PaymentServer.Metrics + ( metricsAPI + , metricsServer + ) +import PaymentServer.Server + ( paymentServerApp + ) +import PaymentServer.Persistence + ( VoucherDatabaseState + ) + +tests :: TestTree +tests = testGroup "Metrics" + [ metricsTests + , serverTests + ] + +readMetrics :: Session SResponse +readMetrics = request $ setPath defaultRequest { requestMethod = methodGet } "/metrics" + +-- Register a counter at the top-level because the registry is global and this +-- lets us avoid thinking about collisions or unregistration. unsafeRegister +-- is (only) safe for defining a top-level symbol. +aCounter = unsafeRegister $ counter (Info "a_counter" "A test counter.") + +metricsTests :: TestTree +metricsTests = + -- | A ``GET /metrics`` request receives a text/plain OK response containing + -- current Prometheus-formatted metrics information. + testCase "plaintext metrics response" $ + let + app :: Application + app = serve metricsAPI metricsServer + in + flip runSession app $ do + incCounter aCounter + response <- readMetrics + assertStatus 200 response + assertContentType "text/plain" response + assertBody expectedMetrics response + where + expectedMetrics = pack . unlines $ + [ "# HELP a_counter A test counter." + , "# TYPE a_counter counter" + , "a_counter 1.0" + ] + +-- | The metrics endpoint is hooked up to the overall application server. +serverTests :: TestTree +serverTests = + testCase "metrics endpoint" $ + let + app :: Application + app = paymentServerApp mempty undefined undefined (undefined :: VoucherDatabaseState) + in + flip runSession app $ do + response <- readMetrics + assertStatus 200 response diff --git a/test/Persistence.hs b/test/Persistence.hs index bd280946a62cf0ed731f5b2297047f0a4ea07705..1f2cd6df0aaa22803656ddb3540f105f1166cc19 100644 --- a/test/Persistence.hs +++ b/test/Persistence.hs @@ -205,16 +205,24 @@ sqlite3DatabaseVoucherPaymentTests = sqlite3Tests = testGroup "SQLite3-specific voucher" [ testCase "database is busy" $ do - getDB <- makeDatabase - db <- getDB - case db of + aDatabase <- makeDatabase + normalConnection <- aDatabase + case normalConnection of (SQLiteDB connect) -> do - conn <- connect - -- Tweak the timeout down so the test completes quickly - Sqlite.execute_ conn "PRAGMA busy_timeout = 0" - -- Acquire a write lock before letting the application code run so that - -- the application code is denied the write lock. - Sqlite.withExclusiveTransaction conn $ do - result <- redeemVoucher db voucher fingerprint + -- Acquire a write lock before letting the application code run + -- so that the application code is denied the write lock. + normalConn <- connect + fastBusyConn <- fastBusyConnection connect + Sqlite.withExclusiveTransaction normalConn $ do + result <- redeemVoucher fastBusyConn voucher fingerprint assertEqual "Redeeming voucher while database busy" result $ Left DatabaseUnavailable ] + where + fastBusyConnection + :: IO Sqlite.Connection + -> IO VoucherDatabaseState + fastBusyConnection connect = do + conn <- connect + -- Tweak the timeout down so the test completes quickly + Sqlite.execute_ conn "PRAGMA busy_timeout = 0" + return . SQLiteDB . return $ conn diff --git a/test/Spec.hs b/test/Spec.hs index 5821f6551290e304aabbdcad998dcc98a44c23ff..e52ca131e7af29d3b094334ff21b853fe43701dd 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -11,10 +11,12 @@ import Test.Tasty ) import qualified Persistence +import qualified Metrics tests :: TestTree tests = testGroup "Tests" [ Persistence.tests + , Metrics.tests ] main = defaultMain tests