Skip to content

Commit 8d5b101

Browse files
authored
refactor(test): provide means to validate metrics and observations
Some helpers are provided for introspecting metrics already (used in JWT cache tests). This change provides facilities to additionally validate emited Observation events. A new Spec module is also implemented, adding basic tests of schema cache reloading - their main goal is to excercise the new infrastructure.
1 parent ae7d4d2 commit 8d5b101

7 files changed

Lines changed: 198 additions & 31 deletions

File tree

postgrest.cabal

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ test-suite observability
307307
main-is: Main.hs
308308
other-modules: ObsHelper
309309
Observation.JwtCache
310+
Observation.MetricsSpec
310311
build-depends: base >= 4.9 && < 4.20
311312
, base64-bytestring >= 1 && < 1.3
312313
, bytestring >= 0.10.8 && < 0.13
@@ -321,6 +322,7 @@ test-suite observability
321322
, postgrest
322323
, prometheus-client >= 1.1.1 && < 1.2.0
323324
, protolude >= 0.3.1 && < 0.4
325+
, text >= 1.2.2 && < 2.2
324326
, wai >= 3.2.1 && < 3.3
325327
ghc-options: -threaded -O0 -Werror -Wall -fwarn-identities
326328
-fno-spec-constr -optP-Wno-nonportable-include-path

src/PostgREST/AppState.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module PostgREST.AppState
1515
, getJwtCacheState
1616
, init
1717
, initWithPool
18+
, putConfig -- For tests TODO refactoring
1819
, putNextListenerDelay
1920
, putSchemaCache
2021
, putPgVersion

src/PostgREST/Observation.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
{-# LANGUAGE DeriveGeneric #-}
12
{-|
23
Module : PostgREST.Observation
34
Description : This module holds an Observation type which is the core of Observability for PostgREST.
@@ -56,6 +57,7 @@ data Observation
5657
| JwtCacheEviction
5758
| TerminationUnixSignalObs Text
5859
| WarpErrorObs Text
60+
deriving (Generic)
5961

6062
data ObsFatalError = ServerAuthError | ServerPgrstBug | ServerError42P05 | ServerError08P01
6163

test/observability/Main.hs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,39 +15,55 @@ import qualified PostgREST.Metrics as Metrics
1515
import PostgREST.SchemaCache (querySchemaCache)
1616

1717
import qualified Observation.JwtCache
18+
import qualified Observation.MetricsSpec
1819

1920
import ObsHelper
20-
import Protolude hiding (toList, toS)
21+
import PostgREST.Observation (Observation (HasqlPoolObs))
22+
import Protolude hiding (toList, toS)
2123
import Test.Hspec
2224

2325
main :: IO ()
2426
main = do
27+
poolChan <- newChan
28+
-- make sure poolChan is not growing indefinitely
29+
-- start a thread that drains the channel
30+
-- this is necessary because test cases operate on
31+
-- copies so poolChan is never read from
32+
-- this means we have another thread running for the entire duration of the spec but this shouldn't be a problem since Haskell green threads are lightweight
33+
void $ forkIO $ forever $ readChan poolChan
34+
metricsState <- Metrics.init (configDbPoolSize testCfg)
2535
pool <- P.acquire $ P.settings
2636
[ P.size 3
2737
, P.acquisitionTimeout 10
2838
, P.agingTimeout 60
2939
, P.idlenessTimeout 60
3040
, P.staticConnectionSettings (toUtf8 $ configDbUri testCfg)
41+
-- make sure metrics are updated and pool observations published to poolChan
42+
, P.observationHandler $ (writeChan poolChan <> Metrics.observationMetrics metricsState) . HasqlPoolObs
3143
]
3244

3345
actualPgVersion <- either (panic . show) id <$> P.use pool (queryPgVersion False)
3446

3547
-- cached schema cache so most tests run fast
3648
baseSchemaCache <- loadSCache pool testCfg
3749
loggerState <- Logger.init
38-
metricsState <- Metrics.init (configDbPoolSize testCfg)
3950

4051
let
41-
initApp sCache st config = do
42-
appState <- AppState.initWithPool pool config loggerState metricsState (Metrics.observationMetrics metricsState)
52+
initApp sCache config = do
53+
-- duplicate poolChan as a starting point
54+
obsChan <- dupChan poolChan
55+
stateObsChan <- newObsChan obsChan
56+
appState <- AppState.initWithPool pool config loggerState metricsState (Metrics.observationMetrics metricsState <> writeChan obsChan)
4357
AppState.putPgVersion appState actualPgVersion
4458
AppState.putSchemaCache appState (Just sCache)
45-
return (st, postgrest (configLogLevel config) appState (pure ()))
59+
return (SpecState appState metricsState stateObsChan, postgrest (configLogLevel config) appState (pure ()))
4660

4761
-- Run all test modules
4862
hspec $ do
49-
before (initApp baseSchemaCache metricsState testCfgJwtCache) $
63+
before (initApp baseSchemaCache testCfgJwtCache) $
5064
describe "Observation.JwtCacheObs" Observation.JwtCache.spec
65+
before (initApp baseSchemaCache testCfg) $
66+
describe "Feature.MetricsSpec" Observation.MetricsSpec.spec
5167

5268
where
5369
loadSCache pool conf =

test/observability/ObsHelper.hs

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,71 @@
11
{-# LANGUAGE AllowAmbiguousTypes #-}
2+
{-# LANGUAGE DeriveAnyClass #-}
23
{-# LANGUAGE ExistentialQuantification #-}
34
{-# LANGUAGE FlexibleContexts #-}
5+
{-# LANGUAGE FlexibleInstances #-}
6+
{-# LANGUAGE LambdaCase #-}
7+
{-# LANGUAGE RankNTypes #-}
48
{-# LANGUAGE ScopedTypeVariables #-}
59
{-# LANGUAGE TupleSections #-}
610
{-# LANGUAGE TypeApplications #-}
11+
{-# LANGUAGE TypeOperators #-}
712
module ObsHelper where
813

9-
import qualified Data.ByteString.Base64 as B64 (decodeLenient)
10-
import qualified Data.ByteString.Char8 as BS
11-
import qualified Data.ByteString.Lazy as BL
12-
import qualified Jose.Jwa as JWT
13-
import qualified Jose.Jws as JWT
14-
import qualified Jose.Jwt as JWT
14+
import qualified Data.ByteString as BS
15+
import qualified Data.ByteString.Base64 as B64
16+
import qualified Data.ByteString.Lazy as BL
17+
import qualified Data.List as DL
18+
import Data.List.NonEmpty (fromList)
19+
import Data.String (String)
20+
import qualified Data.Text as T
21+
import qualified Jose.Jwa as JWT
22+
import qualified Jose.Jws as JWT
23+
import qualified Jose.Jwt as JWT
24+
import Network.HTTP.Types
25+
import qualified PostgREST.AppState as AppState
26+
import PostgREST.Config (AppConfig (..),
27+
JSPathExp (..),
28+
LogLevel (..),
29+
OpenAPIMode (..),
30+
Verbosity (..),
31+
parseSecret)
32+
import qualified PostgREST.Metrics as Metrics
33+
import PostgREST.Observation (Observation (..))
34+
import Prometheus (Counter, getCounter)
35+
import Protolude hiding (get, toS)
36+
import System.Timeout (timeout)
37+
import Test.Hspec
38+
import Test.Hspec.Expectations.Contrib (annotate)
39+
40+
-- helpers used to produce observation diagnostics in waitForObs
41+
-- Implementing the Show instance for Observation is hard due to having many different parameters so instead we use generic programming (`conName`) to obtain the constructor name as `Text`
42+
class HasConstructor f where
43+
genericConstrName :: f x -> Text
44+
45+
instance HasConstructor f => HasConstructor (D1 c f) where
46+
genericConstrName (M1 x) = genericConstrName x
47+
48+
instance (HasConstructor x, HasConstructor y) => HasConstructor (x :+: y) where
49+
genericConstrName (L1 l) = genericConstrName l
50+
genericConstrName (R1 r) = genericConstrName r
51+
52+
instance Constructor c => HasConstructor (C1 c f) where
53+
genericConstrName = T.pack . conName
54+
55+
data SpecState = SpecState {
56+
specAppState :: AppState.AppState,
57+
specMetrics :: Metrics.MetricsState,
58+
specObsChan :: ObsChan
59+
}
1560

16-
import PostgREST.Config (AppConfig (..), JSPathExp (..),
17-
LogLevel (..), OpenAPIMode (..),
18-
Verbosity (..), parseSecret)
61+
data StateCheck st m = forall a. StateCheck (st -> (String, m a)) (a -> a -> Expectation)
1962

20-
import Data.List.NonEmpty (fromList)
21-
import Data.String (String)
22-
import Prometheus (Counter, getCounter)
23-
import Test.Hspec.Expectations.Contrib (annotate)
63+
data TimeoutException = TimeoutException deriving (Show, Exception)
2464

25-
import Network.HTTP.Types
26-
import Protolude
27-
import Test.Hspec
28-
import Test.Hspec.Wai
65+
data ObsChan = ObsChan (Chan Observation) (Chan Observation)
2966

67+
constrName :: (HasConstructor (Rep a), Generic a)=> a -> Text
68+
constrName = genericConstrName . from
3069

3170
baseCfg :: AppConfig
3271
baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in
@@ -109,18 +148,12 @@ generateJWT claims =
109148
either mempty JWT.unJwt $ JWT.hmacEncode JWT.HS256 generateSecret (BL.toStrict claims)
110149

111150
-- state check helpers
112-
113-
data StateCheck st m = forall a. StateCheck (st -> (String, m a)) (a -> a -> Expectation)
114-
115151
stateCheck :: (Show a, Eq a) => (c -> m a) -> (st -> (String, c)) -> (a -> a) -> StateCheck st m
116152
stateCheck extractValue extractComponent expect = StateCheck (second extractValue . extractComponent) (flip shouldBe . expect)
117153

118154
expectField :: forall s st a c m. (KnownSymbol s, Show a, Eq a, HasField s st c) => (c -> m a) -> (a -> a) -> StateCheck st m
119155
expectField extractValue = stateCheck extractValue ((symbolVal (Proxy @s),) . getField @s)
120156

121-
checkState :: (Traversable t) => t (StateCheck st (WaiSession st)) -> WaiSession st b -> WaiSession st ()
122-
checkState checks act = getState >>= flip (`checkState'` checks) act
123-
124157
checkState' :: (Traversable t, MonadIO m) => st -> t (StateCheck st m) -> m b -> m ()
125158
checkState' initialState checks act = do
126159
expectations <- traverse (\(StateCheck g expect) -> let (msg, m) = g initialState in m >>= createExpectation msg m . expect) checks
@@ -133,3 +166,48 @@ expectCounter :: forall s st m. (KnownSymbol s, HasField s st Counter, MonadIO m
133166
expectCounter = expectField @s intCounter
134167
where
135168
intCounter = ((round @Double @Int) <$>) . getCounter
169+
170+
accumulateUntilTimeout :: Int -> (s -> a -> s) -> s -> IO a -> IO s
171+
accumulateUntilTimeout t f start act = do
172+
tid <- myThreadId
173+
-- mask to make sure TimeoutException is not thrown before starting the loop
174+
mask $ \unmask -> do
175+
-- start timeout thread unmasking exceptions
176+
ttid <- forkIOWithUnmask ($ (threadDelay t *> throwTo tid TimeoutException))
177+
-- unmask effect
178+
unmask (fix (\loop accum -> (act >>= loop . f accum) `onTimeout` pure accum) start)
179+
-- make sure we catch timeout if happens before entering the loop
180+
`onTimeout` pure start
181+
-- make sure timer thread is killed on other exceptions
182+
-- so that it won't throw TimeoutException later
183+
`onException` killThread ttid
184+
where
185+
onTimeout m a = m `catch` \TimeoutException -> a
186+
187+
newObsChan :: Chan Observation -> IO ObsChan
188+
newObsChan = fmap <$> ObsChan <*> dupChan
189+
190+
-- read messages from copy chan and once condition is met drain original to the same point
191+
-- upon timeout report error and messages remaining in the original chan
192+
-- that way we report messages since last successful read
193+
waitForObs :: HasCallStack => ObsChan -> Int -> Text -> (Observation -> Maybe a) -> IO ()
194+
waitForObs (ObsChan orig copy) t msg f =
195+
timeout t (readUntil copy *> readUntil orig) >>= maybe failTimeout mempty
196+
where
197+
failTimeout = takeUntilTimeout decisecond (readChan orig)
198+
>>= expectationFailure . DL.unlines . fmap show . (failureMessageHeader :) . fmap obsDiagMessage
199+
failureMessageHeader = "Timeout waiting for " <> msg <> " at " <> loc <> ". Remaining observations:"
200+
readUntil = void . untilM (pure . not . null . f) . readChan
201+
loc = fromMaybe "(unknown)" . head $ (T.pack . prettySrcLoc . snd <$> getCallStack callStack)
202+
-- execute effectful computation until result meets provided condition
203+
untilM cond m = fix $ \loop -> m >>= \a -> ifM (cond a) (pure a) loop
204+
-- duplicate the provided channel and construct wairFor function binding both channels
205+
-- accumulate effecful computation results into a list for specified time
206+
takeUntilTimeout t' = fmap reverse . accumulateUntilTimeout t' (flip (:)) []
207+
decisecond = 100000
208+
209+
obsDiagMessage :: Observation -> Text
210+
obsDiagMessage = \case
211+
(HasqlPoolObs o) -> show o
212+
o@(DBListenStart host port name channel) -> constrName o <> show (host, port, name, channel)
213+
o -> constrName o

test/observability/Observation/JwtCache.hs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE NamedFieldPuns #-}
23
{-# LANGUAGE TypeApplications #-}
34
module Observation.JwtCache where
45

@@ -13,9 +14,11 @@ import PostgREST.Metrics (MetricsState (..))
1314
import Protolude
1415
import Test.Hspec.Wai.JSON (json)
1516

16-
spec :: SpecWith (MetricsState, Application)
17+
spec :: SpecWith (SpecState, Application)
1718
spec = describe "Server started with JWT and metrics enabled" $ do
1819
it "Should not have JWT in cache" $ do
20+
expectCounters <- checkState' . specMetrics <$> getState
21+
1922
let auth = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe1"}|]
2023

2124
expectCounters
@@ -27,6 +30,8 @@ spec = describe "Server started with JWT and metrics enabled" $ do
2730
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200
2831

2932
it "Should have JWT in cache" $ do
33+
expectCounters <- checkState' . specMetrics <$> getState
34+
3035
let auth = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe2"}|]
3136

3237
expectCounters
@@ -39,6 +44,8 @@ spec = describe "Server started with JWT and metrics enabled" $ do
3944
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200
4045

4146
it "Should not cache invalid JWTs" $ do
47+
expectCounters <- checkState' . specMetrics <$> getState
48+
4249
let auth = authHeaderJWT "some random bytes"
4350

4451
expectCounters
@@ -51,6 +58,8 @@ spec = describe "Server started with JWT and metrics enabled" $ do
5158
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
5259

5360
it "Should cache expired JWTs" $ do
61+
expectCounters <- checkState' . specMetrics <$> getState
62+
5463
let auth = genToken [json|{"exp": 1, "role": "postgrest_test_author", "id": "jdoe2"}|]
5564

5665
expectCounters
@@ -63,6 +72,8 @@ spec = describe "Server started with JWT and metrics enabled" $ do
6372
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
6473

6574
it "Should evict entries from the JWT cache (jwt cache max is 2)" $ do
75+
expectCounters <- checkState' . specMetrics <$> getState
76+
6677
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe3"}|]
6778
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe4"}|]
6879
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe5"}|]
@@ -82,6 +93,8 @@ spec = describe "Server started with JWT and metrics enabled" $ do
8293
*> request methodGet "/authors_only" [jwt3] ""
8394

8495
it "Should not evict entries from the JWT cache in FIFO order" $ do
96+
expectCounters <- checkState' . specMetrics <$> getState
97+
8598
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe6"}|]
8699
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe7"}|]
87100
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe8"}|]
@@ -108,6 +121,8 @@ spec = describe "Server started with JWT and metrics enabled" $ do
108121
-- The test case was added based on coverage report
109122
-- showing this scenario was not covered by previous tests
110123
it "Should evict entries even though all were hit" $ do
124+
expectCounters <- checkState' . specMetrics <$> getState
125+
111126
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe9"}|]
112127
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe10"}|]
113128
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe11"}|]
@@ -135,4 +150,3 @@ spec = describe "Server started with JWT and metrics enabled" $ do
135150
requests = expectCounter @"jwtCacheRequests"
136151
hits = expectCounter @"jwtCacheHits"
137152
evictions = expectCounter @"jwtCacheEvictions"
138-
expectCounters = checkState
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE FlexibleContexts #-}
3+
{-# LANGUAGE MonadComprehensions #-}
4+
{-# LANGUAGE NamedFieldPuns #-}
5+
{-# LANGUAGE TypeApplications #-}
6+
7+
module Observation.MetricsSpec where
8+
9+
import Data.List (lookup)
10+
import Network.Wai (Application)
11+
import ObsHelper
12+
import qualified PostgREST.AppState as AppState
13+
import PostgREST.Config (AppConfig (configDbSchemas))
14+
import qualified PostgREST.Metrics as Metrics
15+
import PostgREST.Observation
16+
import Prometheus (getCounter, getVectorWith)
17+
import Protolude
18+
import Test.Hspec (SpecWith, describe, it)
19+
import Test.Hspec.Wai (getState)
20+
21+
spec :: SpecWith (SpecState, Application)
22+
spec = describe "Server started with metrics enabled" $ do
23+
it "Should update pgrst_schema_cache_loads_total[SUCCESS]" $ do
24+
SpecState{specAppState = appState, specMetrics = metrics, specObsChan} <- getState
25+
let waitFor = waitForObs specObsChan
26+
27+
liftIO $ checkState' metrics [
28+
schemaCacheLoads "SUCCESS" (+1)
29+
] $ do
30+
AppState.schemaCacheLoader appState
31+
waitFor (1 * sec) "SchemaCacheLoadedObs" $ \x -> [ o | o@(SchemaCacheLoadedObs{}) <- pure x]
32+
33+
it "Should update pgrst_schema_cache_loads_total[ERROR]" $ do
34+
SpecState{specAppState = appState, specMetrics = metrics, specObsChan} <- getState
35+
let waitFor = waitForObs specObsChan
36+
37+
liftIO $ checkState' metrics [
38+
schemaCacheLoads "FAIL" (+1),
39+
schemaCacheLoads "SUCCESS" (+1)
40+
] $ do
41+
AppState.getConfig appState >>= \prev -> do
42+
AppState.putConfig appState $ prev { configDbSchemas = pure "bad_schema" }
43+
AppState.schemaCacheLoader appState
44+
waitFor (1 * sec) "SchemaCacheErrorObs" $ \x -> [ o | o@(SchemaCacheErrorObs{}) <- pure x]
45+
AppState.putConfig appState prev
46+
47+
-- wait up to 2 secs so that retry can happen
48+
waitFor (2 * sec) "SchemaCacheLoadedObs" $ \x -> [ o | o@(SchemaCacheLoadedObs{}) <- pure x]
49+
50+
where
51+
-- prometheus-client api to handle vectors is convoluted
52+
schemaCacheLoads label = expectField @"schemaCacheLoads" $
53+
fmap (maybe (0::Int) round . lookup label) . (`getVectorWith` getCounter)
54+
sec = 1000000

0 commit comments

Comments
 (0)