on master help sign in
Add test suite, fix some bugs

This should be ready to test drive in production. My initial GHC tests look good.

commit3ffac14cf30da6dffa8192de141688f72d77ce17 authorMichael Xavier <michael@michaelxavier.net> dateTue 22 Jan 2019, 09:42 (8 years ago) parent6f91d29
5 files changed+816−78
katip-logzio/katip-logzio.cabal+34−6 katip-logzio/src/Katip/Scribes/LogzIO/HTTPS.hs+330−69 katip-logzio/test/Katip/Tests/Scribes/LogzIO/HTTPS.hs+448 katip-logzio/test/Main.hs+3−2 katip/src/Katip/Core.hs+1−1
katip-logzio/katip-logzio.cabal+34−6
@@ -15,7 +15,6 @@ extra-source-files:
1515 README.md
1616 changelog.md
1717
18−--TODO: fill this in
1918tested-with: GHC == 8.4.3
2019
2120source-repository head
@@ -33,17 +32,25 @@ library
3332 build-depends:
3433 base >=4.8.0.0 && <5
3534 , katip >= 0.8.0.0
36− , safe-exceptions
3735 , stm >= 2.1.1
3836 , async
39− , time
37+ , time >= 1.8.0.2
4038 , aeson >= 0.10.0.0
4139 , unix
4240 , unordered-containers
41+ , http-client >= 0.1.0
42+ , http-client-tls >= 0.3.4
43+ , uri-bytestring
44+ , errors >= 2.0.0
45+ , safe-exceptions
46+ , retry
47+ , http-types
48+ , text
49+ , bytestring
4350 hs-source-dirs: src
4451 default-language: Haskell2010
4552 hs-source-dirs: src
46− ghc-options: -Wall
53+ ghc-options: -Wall -j -Wall -Wnoncanonical-monad-instances
4754 if flag(lib-Werror)
4855 ghc-options: -Werror
4956
@@ -53,11 +60,32 @@ test-suite test
5360 main-is: Main.hs
5461 hs-source-dirs: test
5562 default-language: Haskell2010
56− -- other-modules:
63+ other-modules:
64+ Katip.Tests.Scribes.LogzIO.HTTPS
5765 build-depends: base
5866 , katip
5967 , katip-logzio
6068 , tasty
61− ghc-options: -Wall
69+ , tasty-hedgehog
70+ , tasty-hunit
71+ , hedgehog
72+ , vector
73+ , unordered-containers
74+ , text
75+ , bytestring
76+ , time
77+ , template-haskell
78+ , aeson
79+ , scientific
80+ , unix
81+ , hostname
82+ , scotty
83+ , stm
84+ , async
85+ , safe-exceptions
86+ , uri-bytestring
87+ , warp
88+ , http-types
89+ ghc-options: -j -Wall -threaded -rtsopts -with-rtsopts=-N -Wnoncanonical-monad-instances
6290 if flag(lib-Werror)
6391 ghc-options: -Werror
katip-logzio/src/Katip/Scribes/LogzIO/HTTPS.hs+330−69
@@ -1,3 +1,8 @@
1+{-# LANGUAGE BangPatterns #-}
2+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
3+{-# LANGUAGE OverloadedStrings #-}
4+{-# LANGUAGE RankNTypes #-}
5+{-# LANGUAGE TypeApplications #-}
16-- | This is a log scribe that writes logs to logz.io's bulk
27-- <https://app.logz.io/#/dashboard/data-sources/Bulk-HTTPS HTTPS
38-- API>.
@@ -7,24 +12,56 @@ module Katip.Scribes.LogzIO.HTTPS
712 -- * Types
813 , BulkAPIError(..)
914 , LogzIOScribeConfiguration(..)
15+ , Scheme(..)
16+ , APIToken(..)
17+ , LoggingError(..)
18+ -- ** Presets for configuration
19+ , usRegionHost
20+ , euRegionHost
21+ , httpsPort
22+ , httpPort
23+ , defaultRetryPolicy
24+ , defaultLogzIOScribeConfiguration
25+ -- * Internal API exported for testing
26+ , renderLineTruncated
27+ , renderLineTruncated'
28+ , maxPayloadBytes
29+ , maxLogLineLength
30+ , BulkBuffer (..)
31+ , LogAction (..)
32+ , bufferItem
33+ , bufferItem'
34+ , forceFlush
35+ , Bytes (..)
1036 ) where
1137
1238
1339-------------------------------------------------------------------------------
1440import Control.Applicative
15−import qualified Control.Concurrent.Async as Async
16−import qualified Control.Concurrent.STM as STM
17−import qualified Data.Aeson as A
18−import qualified Data.ByteString.Builder as BB
19−import qualified Data.ByteString.Lazy as LBS
20−import qualified Data.HashMap.Strict as HM
41+import qualified Control.Concurrent.Async as Async
42+import qualified Control.Concurrent.STM as STM
43+import qualified Control.Error as E
44+import qualified Control.Exception.Safe as EX
45+import qualified Control.Retry as Retry
46+import qualified Data.Aeson as A
47+import qualified Data.ByteString.Builder as BB
48+import qualified Data.ByteString.Lazy as LBS
49+import qualified Data.ByteString.Lazy.Char8 as LBS8
50+import qualified Data.HashMap.Strict as HM
2151import Data.Int
22−import qualified Data.Text.Lazy as TL
23−import qualified Data.Text.Lazy.Builder as TB
24−import qualified Data.Time as Time
25−import qualified Katip as K
26−import Katip.Core (LocJs (..))
27−import qualified System.Posix.Types as POSIX
52+import Data.String (IsString)
53+import qualified Data.Text as T
54+import qualified Data.Text.Encoding as TE
55+import qualified Data.Text.Lazy as TL
56+import qualified Data.Text.Lazy.Builder as TB
57+import qualified Data.Time as Time
58+import qualified Katip as K
59+import Katip.Core (LocJs (..))
60+import qualified Network.HTTP.Client as HTTP
61+import qualified Network.HTTP.Client.TLS as HTTPS
62+import qualified Network.HTTP.Types as HTypes
63+import qualified System.Posix.Types as POSIX
64+import qualified URI.ByteString as URIBS
2865-------------------------------------------------------------------------------
2966-------------------------------------------------------------------------------
3067
@@ -47,7 +84,21 @@ data BulkAPIError = BulkAPIError
4784 -- concept of a truly empty log line in katip, so this most likely
4885 -- indicates a __library bug__ and should be reported as an issue on
4986 -- GitHub.
50− }
87+ } deriving (Show, Eq)
88+
89+
90+instance A.FromJSON BulkAPIError where
91+ parseJSON = A.withObject "BulkAPIError" $ \o -> do
92+ malformedLines <- o A..: "malformedLines"
93+ successfulLines <- o A..: "successfulLines"
94+ oversizedLines <- o A..: "oversizedLines"
95+ emptyLogLines <- o A..: "emptyLogLines"
96+ pure $ BulkAPIError
97+ { bulkAPIError_malformedLines = malformedLines
98+ , bulkAPIError_successfulLines = successfulLines
99+ , bulkAPIError_oversizedLines = oversizedLines
100+ , bulkAPIError_emptyLogLines = emptyLogLines
101+ }
51102
52103
53104-------------------------------------------------------------------------------
@@ -59,10 +110,94 @@ data LogzIOScribeConfiguration = LogzIOScribeConfiguration
59110 , logzIOScribeConfiguration_bufferTimeout :: Time.NominalDiffTime
60111 -- ^ Will flush the buffer if it has been this long since the last
61112 -- flush __or__ 'logzIOScribeConfiguration_bufferItems' items are
62− -- accumulated, whichever is first.
63− --TODO: more
113+ -- accumulated, whichever is first. NominalDiffTime has a Num
114+ -- instance, so you can use a literal to specify seconds, e.g. 30 =
115+ -- 30s.
116+ , logzIOScribeConfiguration_scheme :: Scheme
117+ , logzIOScribeConfiguration_host :: URIBS.Host
118+ , logzIOScribeConfiguration_port :: URIBS.Port
119+ , logzIOScribeConfiguration_token :: APIToken
120+ , logzIOScribeConfiguration_retry :: Retry.RetryPolicyM IO
121+ -- ^ How should exceptions during writes be retried?
122+ , logzIOScribeConfiguration_onError :: LoggingError -> IO ()
123+ }
124+
125+
126+-- | A default configuration:
127+--
128+-- * 100 item buffering
129+--
130+-- * 30 second buffer timeout
131+--
132+-- * US, HTTPS logging endpoint (listener.logz.io:8071)
133+--
134+-- * 'defaultRetryPolicy' of 25ms exponential backoff, 5 retries
135+--
136+-- * Ignore logging errors
137+defaultLogzIOScribeConfiguration :: APIToken -> LogzIOScribeConfiguration
138+defaultLogzIOScribeConfiguration token = LogzIOScribeConfiguration
139+ { logzIOScribeConfiguration_bufferItems = 100
140+ , logzIOScribeConfiguration_bufferTimeout = 30
141+ , logzIOScribeConfiguration_scheme = HTTPS
142+ , logzIOScribeConfiguration_host = usRegionHost
143+ , logzIOScribeConfiguration_port = httpsPort
144+ , logzIOScribeConfiguration_token = token
145+ , logzIOScribeConfiguration_retry = defaultRetryPolicy
146+ , logzIOScribeConfiguration_onError = const (pure ())
64147 }
65148
149+
150+-------------------------------------------------------------------------------
151+-- | You can retrieve your account or sub-account's API token on the
152+-- <https://app.logz.io/#/dashboard/settings/manage-accounts manage
153+-- accounts page>. Note that APIToken has an IsString instance,
154+-- meaning that you can use a string literal with OverloadedStrings
155+-- enabled.
156+newtype APIToken = APIToken
157+ { apiToken :: T.Text
158+ } deriving (Show, Eq, IsString)
159+
160+
161+-- | This particular bulk API only supports HTTP or HTTPS. HTTPS is
162+-- strongly recommended for security reasons.
163+data Scheme
164+ = HTTPS
165+ -- ^ HTTPs should always be used except for local testing.
166+ | HTTP
167+ deriving (Show, Eq)
168+
169+
170+-- | See
171+-- <https://support.logz.io/hc/en-us/articles/210206365-What-IP-addresses-should-I-open-in-my-firewall-to-ship-logs-to-Logz-io-
172+-- this> for a list of listeners. This is the US region host,
173+-- listener.logz.io
174+usRegionHost :: URIBS.Host
175+usRegionHost = URIBS.Host "listener.logz.io"
176+
177+
178+-- | See
179+-- <https://support.logz.io/hc/en-us/articles/210206365-What-IP-addresses-should-I-open-in-my-firewall-to-ship-logs-to-Logz-io-
180+-- this> for a list of listeners. This is the EU region host,
181+-- listener.logz.io
182+euRegionHost :: URIBS.Host
183+euRegionHost = URIBS.Host "listener-eu.logz.io"
184+
185+
186+-- | Logz.io uses port 8071 for HTTPS
187+httpsPort :: URIBS.Port
188+httpsPort = URIBS.Port 8071
189+
190+
191+-- | Logz.io uses port 8070 for HTTP
192+httpPort :: URIBS.Port
193+httpPort = URIBS.Port 8070
194+
195+
196+-- | A reasonable retry policy: exponential backoff with 25ms base
197+-- delay up to 5 retries, for a total cumulative delay of 775ms.
198+defaultRetryPolicy :: (Monad m) => Retry.RetryPolicyM m
199+defaultRetryPolicy = Retry.exponentialBackoff 25000 <> Retry.limitRetries 5
200+
66201-------------------------------------------------------------------------------
67202mkLogzIOScribe
68203 :: LogzIOScribeConfiguration
@@ -73,7 +208,8 @@ mkLogzIOScribe config permitItem verbosity = do
73208 -- Accumulating buffer for each item
74209 bufferState <- STM.newTVarIO (mempty :: BulkBuffer)
75210 -- Single-slot TMVar when something is ready to write. Katip buffers
76− -- for us so we can simplify shutdown and flush guarantees this way
211+ -- for us upstream so we can simplify shutdown and flush guarantees
212+ -- by synchronously writing when we need to.
77213 readyMVar <- STM.newEmptyTMVarIO :: IO (STM.TMVar BulkBuffer)
78214
79215 -- We create a TVar that is set to ShutDown when its time to stop
@@ -82,38 +218,39 @@ mkLogzIOScribe config permitItem verbosity = do
82218 -- Create a mutable ref to whether the timer has expired. This lets
83219 -- us replace the timer when its time to start timing again. We
84220 -- reset the timer __after__ a write goes out, not as soon as it expires
85− timerRef <- STM.atomically $ do
86− -- If there's never been a log, no need to start the timer
87− never <- STM.newTVar False
88− STM.newTVar never
89−
90−
91− -- on every push, extend deadline
92− -- thread waits for deadline and pushes. need a way to be atomic though, cancelthread is heavy handed
93− --TODO: strictness
94−
95− -- in STM: resolve an action with the buffers
96− let processLogAction logAction = case logAction of
97− Buffered newBuffer -> STM.writeTVar bufferState newBuffer
98− FlushNow toFlush newBuffer -> do
221+ timerRef <- STM.newTVarIO =<< STM.registerDelay itemBufferTimeoutMicros
222+
223+ -- Set up a connection manager for requests
224+ mgr <- case scheme of
225+ HTTPS -> HTTPS.newTlsManager
226+ HTTP -> HTTP.newManager HTTP.defaultManagerSettings
227+
228+ -- In STM: resolve an action with the buffers
229+ let processLogAction :: LogAction BulkBuffer -> STM.STM ()
230+ processLogAction logAction = case logAction of
231+ -- No write needed right now
232+ Buffered !newBuffer -> STM.writeTVar bufferState newBuffer
233+ -- Time to flush and put excess in the next buffer
234+ FlushNow !toFlush !newBuffer -> do
99235 STM.writeTVar bufferState newBuffer
100236 STM.putTMVar readyMVar toFlush
101237
102− -- In recompute the new buffer state from a new log item and take action
103− let push logItem = do
104− curBuffer <- STM.readTVar bufferState
238+ -- Recompute the new buffer state from a new log item and take action
239+ let push :: (K.LogItem a) => K.Item a -> STM.STM ()
240+ push !logItem = do
241+ !curBuffer <- STM.readTVar bufferState
105242 processLogAction (bufferItem itemBufferSize verbosity logItem curBuffer)
106243
107244
108245 -- A transaction that retries until its time to shutdown
109− let waitShutdown = do
246+ let waitShutdown :: STM.STM ()
247+ waitShutdown = do
110248 killStatus <- STM.readTVar killSwitch
111249 STM.check (killStatus == ShutDown)
112250
113− --TODO: exception handling
114−
115− -- Retry transaction until timer has expired
116− let waitTimer = do
251+ -- Retry transaction until timer has expired. When it does, take action on the buffer.
252+ let waitTimer :: STM.STM ()
253+ waitTimer = do
117254 -- get the timer we'll be using
118255 timer <- STM.readTVar timerRef
119256 -- do not proceed until its ready
@@ -122,12 +259,13 @@ mkLogzIOScribe config permitItem verbosity = do
122259 -- do not wake again until this message is out the door
123260 STM.writeTVar timer False
124261 -- we waited long enough, grab the current buffer
125− curBuffer <- STM.readTVar bufferState
262+ !curBuffer <- STM.readTVar bufferState
126263 -- and tell it to flush right now
127264 processLogAction (forceFlush curBuffer)
128265
129− -- loop until its time to shut down
130− let timedFlushLoop = do
266+ -- run the timer in a loop until its time to shut down
267+ let timedFlushLoop :: IO ()
268+ timedFlushLoop = do
131269 res <- STM.atomically $
132270 raceAlt
133271 waitShutdown
@@ -138,7 +276,9 @@ mkLogzIOScribe config permitItem verbosity = do
138276
139277 timedFlusher <- Async.async timedFlushLoop
140278
141− let flushLoop = do
279+ -- flush the buffer until its time to shutdown and we're out of items to log
280+ let flushLoop :: IO ()
281+ flushLoop = do
142282 -- hold until something comes in to flush or we're told to stop. If we're told to stop but get a log first, we'll catch it on the next loop
143283 readyBufferOrDie <- STM.atomically $
144284 raceAlt
@@ -149,16 +289,24 @@ mkLogzIOScribe config permitItem verbosity = do
149289 -- There's some logs to write
150290 Left readyBuffer -> do
151291 -- flush in IO
152− flushBuffer readyBuffer
292+ res <- flushBuffer config mgr readyBuffer
293+ case res of
294+ Left ex -> onErrorSafe ex
295+ Right () -> pure ()
153296 -- reset the timeout before the next flush on each write
154− newTimer <- STM.registerDelay itemBufferTimeoutMicros
297+ !newTimer <- STM.registerDelay itemBufferTimeoutMicros
155298 STM.atomically $ do
156299 STM.writeTVar timerRef newTimer
157300 -- start over
158301 flushLoop
159302 flusher <- Async.async flushLoop
160303
161− let shutdown = do
304+ -- Blocking call which sends a poison pill to the workers and waits
305+ -- for them to exit, swallowing exceptions. This follows the
306+ -- semantics that when katip calls the finalizer, no more log
307+ -- entries will be sent.
308+ let shutdown :: IO ()
309+ shutdown = do
162310 -- tell workers this is the last loop
163311 STM.atomically (STM.writeTVar killSwitch ShutDown)
164312 -- wait for workers to finish
@@ -169,13 +317,18 @@ mkLogzIOScribe config permitItem verbosity = do
169317
170318
171319 pure $ K.Scribe
172− { liPush = STM.atomically . push
173− , scribeFinalizer = shutdown
174− , scribePermitItem = permitItem
320+ { K.liPush = STM.atomically . push
321+ , K.scribeFinalizer = shutdown
322+ , K.scribePermitItem = permitItem
175323 }
176324 where
177325 itemBufferSize = logzIOScribeConfiguration_bufferItems config
178326 itemBufferTimeoutMicros = ndtToMicros (logzIOScribeConfiguration_bufferTimeout config)
327+ scheme = logzIOScribeConfiguration_scheme config
328+ -- should this be async?
329+ onErrorSafe ex = do
330+ _ <- EX.tryAny (logzIOScribeConfiguration_onError config ex)
331+ pure ()
179332
180333
181334-------------------------------------------------------------------------------
@@ -196,6 +349,9 @@ ndtToMicros t = round (((fromIntegral (ndtPicos t)) :: Double) / picosInMicro)
196349
197350
198351-------------------------------------------------------------------------------
352+-- | An internal mode flag used to shut down worker
353+-- threads. Internally, we assume htere is no going back from
354+-- ShutDown.
199355data KillSwitch =
200356 Continue
201357 | ShutDown
@@ -203,14 +359,84 @@ data KillSwitch =
203359
204360
205361-------------------------------------------------------------------------------
362+-- | When applied to STM, it will try the left side first and if that
363+-- retries, the right, and if that retries, start from the beginning.
206364raceAlt :: (Alternative m) => m a -> m b -> m (Either a b)
207365raceAlt a b = (Left <$> a) <|> (Right <$> b)
208366
209367
210368-------------------------------------------------------------------------------
211−-- TODO: noop on empty
212−flushBuffer :: BulkBuffer -> IO ()
213−flushBuffer = undefined
369+data LoggingError =
370+ URIError HTTP.HttpException
371+ -- ^ The URI generated was invalid. Check your configuration
372+ | RequestError HTTP.HttpException
373+ -- ^ We encountered an exception while sending the batch request
374+ | PartialFailure BulkAPIError
375+ -- ^ Some or all of the request was rejected. Check the logz.io UI
376+ -- for indexing errors.
377+ | BadToken
378+ -- ^ Your API token was rejected.
379+ | UnknownFailureResponse HTypes.Status LBS.ByteString
380+ -- ^ An error returned, but it could not be decoded into a
381+ -- 'BulkAPIError'. This may indicate a __library bug__, which should
382+ -- be reported to the issue tracker.
383+ deriving (Show)
384+
385+-------------------------------------------------------------------------------
386+flushBuffer
387+ :: LogzIOScribeConfiguration
388+ -> HTTP.Manager
389+ -> BulkBuffer
390+ -> IO (Either LoggingError ())
391+flushBuffer config mgr bulkBuffer
392+ | bulkBuffer_itemCount bulkBuffer <= 0 = pure (Right ())
393+ | otherwise = do
394+ E.runExceptT $ do
395+ req <- E.fmapLT URIError (E.ExceptT (EX.try (configureRequest <$> HTTP.parseRequest uriStr)))
396+ resp <- E.fmapLT RequestError $ E.ExceptT $ EX.try $
397+ Retry.recovering retryPolicy [\_stat -> EX.Handler handleHttpException] $ \_stat ->
398+ HTTP.httpLbs req mgr
399+ let respLBS = HTTP.responseBody resp
400+ let respStatus = HTTP.responseStatus resp
401+ if HTypes.statusIsSuccessful respStatus
402+ then pure ()
403+ else case A.decode @BulkAPIError respLBS of
404+ Nothing
405+ | HTypes.statusCode respStatus == 401 -> E.throwE BadToken
406+ | otherwise -> E.throwE (UnknownFailureResponse respStatus respLBS)
407+ Just bulkError -> E.throwE (PartialFailure bulkError)
408+ where
409+ configureRequest req = req
410+ { HTTP.method = HTypes.methodPost
411+ , HTTP.requestBody = HTTP.RequestBodyLBS (BB.toLazyByteString (bulkBuffer_payload bulkBuffer))
412+ }
413+
414+ retryPolicy = logzIOScribeConfiguration_retry config
415+ apiTokenBS = TE.encodeUtf8 (apiToken (logzIOScribeConfiguration_token config))
416+
417+ handleHttpException :: (Applicative m) => HTTP.HttpException -> m Bool
418+ handleHttpException _ = pure True
419+
420+ uriStr = LBS8.unpack (BB.toLazyByteString (URIBS.serializeURIRef uri))
421+
422+ authority = URIBS.Authority
423+ { URIBS.authorityUserInfo = Nothing
424+ , URIBS.authorityHost = logzIOScribeConfiguration_host config
425+ , URIBS.authorityPort = Just (logzIOScribeConfiguration_port config)
426+ }
427+
428+ uri = URIBS.URI
429+ { URIBS.uriScheme = case logzIOScribeConfiguration_scheme config of
430+ HTTPS -> URIBS.Scheme "https"
431+ HTTP -> URIBS.Scheme "http"
432+ , URIBS.uriAuthority = Just authority
433+ , URIBS.uriPath = "/"
434+ , URIBS.uriQuery = URIBS.Query
435+ [ ("token", apiTokenBS)
436+ ]
437+ , URIBS.uriFragment = Nothing
438+ }
439+
214440
215441-------------------------------------------------------------------------------
216442newtype Bytes = Bytes
@@ -231,10 +457,10 @@ maxLogLineLength :: Bytes
231457maxLogLineLength = 500000
232458
233459
234−measureJSON :: A.ToJSON a => a -> (BB.Builder, Bytes)
235−measureJSON a = (BB.lazyByteString lbs, Bytes (LBS.length lbs))
460+measureJSONLine :: A.ToJSON a => a -> (BB.Builder, Bytes)
461+measureJSONLine a = (BB.lazyByteString lbs, Bytes (LBS.length lbs))
236462 where
237− lbs = A.encode a
463+ lbs = A.encode a <> "\n"
238464
239465
240466-- | Fully-rendered JSON object for an item
@@ -249,32 +475,48 @@ fullItemObject verbosity item = HM.fromList
249475 , "data" A..= K.payloadObject verbosity (K._itemPayload item)
250476 -- Slight deviation, logz.io uses "message" instead of "msg"
251477 , "message" A..= TB.toLazyText (K.unLogStr (K._itemMessage item))
252− -- Another slight deviation, logz.io uses "@timestamp" instead
253− -- of "at". Note, your logs should be sent roughly close to when
254− -- they are created. They are assigned to indexes based on index
255− -- date, so time searches act strange if you backfill too far
256− -- from the past.
257− , "@timestamp" A..= K._itemTime item --TODO: explicit formatting as per spec?
478+ -- Another slight deviation, logz.io uses "@timestamp" instead of
479+ -- "at". Note, your logs should be sent roughly close to when they
480+ -- are created. They are assigned to indexes based on index date, so
481+ -- time searches act strange if you backfill too far from the
482+ -- past. They seem to support 3 decimal places of
483+ -- precision. Formatting like this requires time 1.8.0.2, which is
484+ -- reflected in the cabal file.
485+ , "@timestamp" A..= A.String (T.pack (Time.formatTime Time.defaultTimeLocale "%Y-%m-%dT%H:%M:%03QZ" (K._itemTime item)))
258486 , "ns" A..= K._itemNamespace item
259487 , "loc" A..= (LocJs <$> K._itemLoc item)
260488 ]
261489 where
262490 POSIX.CPid pidInt = K._itemProcess item
263491
264−--TODO: property test
265492
266493-- | A version of 'renderLine' which renders a line and stays under
267494-- the maximum line size of 500,000 bytes. If the default rendering is
268495-- too large, the log will be reduced to a timestamp and a potentially
269496-- truncated message.
270497renderLineTruncated :: K.LogItem a => K.Verbosity -> K.Item a -> (BB.Builder, Bytes)
271−renderLineTruncated verbosity item =
272− if fullSize <= maxLogLineLength
498+renderLineTruncated = renderLineTruncated' maxLogLineLength
499+
500+
501+-- | A generalized renderLineTruncated that takes a custom line length
502+-- limit. This is exclusively for testing.
503+renderLineTruncated'
504+ :: K.LogItem a
505+ => Bytes
506+ -- ^ Custom max log line length. Be careful, too low and no amount
507+ -- of shrinking can get under the size, breaking the invariant. For
508+ -- the production limit, we are guraanteed always able to come in
509+ -- under the limit.
510+ -> K.Verbosity
511+ -> K.Item a
512+ -> (BB.Builder, Bytes)
513+renderLineTruncated' customMaxLogLineLength verbosity item =
514+ if fullSize <= customMaxLogLineLength
273515 then (fullLine, fullSize)
274516 else (fallbackLine, fallbackSize)
275517 where
276518 fullObject = fullItemObject verbosity item
277− (fullLine, fullSize) = measureJSON fullObject
519+ (fullLine, fullSize) = measureJSONLine fullObject
278520 -- only the absolutely necessary keys, with message stripped out
279521 -- to help us calculate how much of the message we can keep. these
280522 -- are lazily evaluated and as such won't be computed unless the
@@ -284,9 +526,9 @@ renderLineTruncated verbosity item =
284526 [ "message" A..= A.String "" -- we'll start with a blank message
285527 , "@timestamp" A..= K._itemTime item
286528 ]
287− (_, blankObjectSize) = measureJSON blankObject
529+ (_, blankObjectSize) = measureJSONLine blankObject
288530 messageBytesAllowed = maxLogLineLength - blankObjectSize
289− (fallbackLine, fallbackSize) = measureJSON fallbackObject
531+ (fallbackLine, fallbackSize) = measureJSONLine fallbackObject
290532 fallbackObject :: A.Object
291533 fallbackObject = HM.fromList
292534 [ "message" A..= A.toJSON (TL.take (bytes messageBytesAllowed) (TB.toLazyText (K.unLogStr (K._itemMessage item))))
@@ -332,11 +574,30 @@ bufferItem
332574 -> K.Item a
333575 -> BulkBuffer
334576 -> LogAction BulkBuffer
335−bufferItem maxItems verb item bulkBuffer =
336− let (encodedLine, spaceNeeded) = renderLineTruncated verb item
577+bufferItem = bufferItem' maxPayloadBytes maxLogLineLength
578+
579+
580+-- | An internal version with a configurable max item size and max
581+-- message size for testing. Be careful with this: if you set the
582+-- values too low, some of the guarantees about reducability of
583+-- messages break down.
584+bufferItem'
585+ :: (K.LogItem a)
586+ => Bytes
587+ -- ^ Max payload size
588+ -> Bytes
589+ -- ^ Max item size
590+ -> Int
591+ -- ^ Maximum items before flushing
592+ -> K.Verbosity
593+ -> K.Item a
594+ -> BulkBuffer
595+ -> LogAction BulkBuffer
596+bufferItem' customMaxPayload customMaxItem maxItems verb item bulkBuffer =
597+ let (encodedLine, spaceNeeded) = renderLineTruncated' customMaxItem verb item
337598 newBytesUsed = bulkBuffer_bytesUsed bulkBuffer + spaceNeeded
338599 newItemCount = bulkBuffer_itemCount bulkBuffer + 1
339− in if newItemCount >= maxItems || newBytesUsed >= maxPayloadBytes
600+ in if newItemCount > maxItems || newBytesUsed > customMaxPayload
340601 then FlushNow
341602 bulkBuffer
342603 BulkBuffer
katip-logzio/test/Katip/Tests/Scribes/LogzIO/HTTPS.hsnew file+448
@@ -0,0 +1,448 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+module Katip.Tests.Scribes.LogzIO.HTTPS
3+ ( tests
4+ ) where
5+
6+
7+-------------------------------------------------------------------------------
8+import Control.Concurrent.Async
9+import Control.Concurrent.STM
10+import Control.Concurrent.STM.TSem
11+import Control.Exception.Safe
12+import Control.Monad
13+import Control.Monad.IO.Class
14+import Data.Aeson as A
15+import qualified Data.ByteString.Builder as BB
16+import qualified Data.ByteString.Lazy as LBS
17+import qualified Data.ByteString.Lazy.Char8 as LBS8
18+import qualified Data.HashMap.Strict as HM
19+import Data.Int
20+import Data.Scientific
21+import Data.Text (Text)
22+import Data.Time
23+import Data.Time.Clock.POSIX
24+import qualified Data.Vector as V
25+import Hedgehog as HH
26+import qualified Hedgehog.Gen as Gen
27+import qualified Hedgehog.Range as Range
28+import Katip
29+import Katip.Core
30+import Language.Haskell.TH (Loc (..))
31+import Network.HostName
32+import qualified Network.HTTP.Types.Status as HTypes
33+import qualified Network.Wai.Handler.Warp as Warp
34+import System.Posix
35+import System.Timeout
36+import Test.Tasty
37+import Test.Tasty.Hedgehog
38+import Test.Tasty.HUnit
39+import URI.ByteString
40+import qualified Web.Scotty as Scotty
41+-------------------------------------------------------------------------------
42+import Katip.Scribes.LogzIO.HTTPS
43+-------------------------------------------------------------------------------
44+
45+
46+tests :: TestTree
47+tests = testGroup "Katip.Scribes.LogzIO.HTTP"
48+ [ bufferItemTests
49+ , renderLineTruncatedTests
50+ , scribeTests
51+ ]
52+
53+
54+-------------------------------------------------------------------------------
55+-- We bind to a system port so we use a lock on these tests to avoid interleaved, concurrent requests
56+scribeTests :: TestTree
57+scribeTests = withLock $ \mkSem -> testGroup "scribe"
58+ [ testCase "writes well-formed log items as lines to the given API" $ do
59+ sem <- mkSem
60+ withServer sem $ \receivedMessages -> do
61+ withScribe (\cfg -> cfg { logzIOScribeConfiguration_bufferTimeout = 1}) $ \scribe -> do
62+ (liPush scribe) (genericLog "a message")
63+
64+ requestLog <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
65+ requestLog_token requestLog @?= testAPIToken
66+ let logs = requestLog_logs requestLog
67+ length logs @?= 1
68+ let firstLog = head logs
69+ receivedLog_message firstLog @?= "a message"
70+ , testCase "respects the items limit" $ do
71+ sem <- mkSem
72+ withServer sem $ \receivedMessages -> do
73+ withScribe (\cfg -> cfg { logzIOScribeConfiguration_bufferItems = 1, logzIOScribeConfiguration_bufferTimeout = 1}) $ \scribe -> do
74+ (liPush scribe) (genericLog "message 1")
75+ (liPush scribe) (genericLog "message 2")
76+
77+ log1 <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
78+ requestLog_token log1 @?= testAPIToken
79+ let logs1 = requestLog_logs log1
80+ length logs1 @?= 1
81+ let firstLog1 = head logs1
82+ receivedLog_message firstLog1 @?= "message 1"
83+
84+ log2 <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
85+ let logs2 = requestLog_logs log2
86+ length logs2 @?= 1
87+ let firstLog2 = head logs2
88+ receivedLog_message firstLog2 @?= "message 2"
89+ , testCase "respects the time limit" $ do
90+ sem <- mkSem
91+ withServer sem $ \receivedMessages -> do
92+ withScribe (\cfg -> cfg { logzIOScribeConfiguration_bufferItems = 2, logzIOScribeConfiguration_bufferTimeout = 1}) $ \scribe -> do
93+ (liPush scribe) (genericLog "a message")
94+ log1 <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
95+ requestLog_token log1 @?= testAPIToken
96+ let logs1 = requestLog_logs log1
97+ length logs1 @?= 1
98+ let firstLog1 = head logs1
99+ receivedLog_message firstLog1 @?= "a message"
100+ , testCase "reports errors" $ do
101+ sem <- mkSem
102+ let badToken = APIToken "badtoken"
103+ errorSignal <- newEmptyTMVarIO
104+ withServer sem $ \_receivedMessages -> do
105+ withScribe (\cfg -> cfg { logzIOScribeConfiguration_bufferItems = 1, logzIOScribeConfiguration_bufferTimeout = 1, logzIOScribeConfiguration_token = badToken, logzIOScribeConfiguration_onError = void . atomically . tryPutTMVar errorSignal}) $ \scribe -> do
106+ (liPush scribe) (genericLog "a message")
107+ loggingError <- timeoutFailure (20 * second) (atomically (takeTMVar errorSignal))
108+ case loggingError of
109+ BadToken -> pure ()
110+ _ -> assertFailure ("Expected BadToken error but got " <> show loggingError)
111+ ]
112+ where
113+ second = 1000000
114+ timeoutFailure waitTime f = do
115+ res <- timeout waitTime f
116+ case res of
117+ Nothing -> assertFailure ("Operation timed out after " <> show waitTime <> "us")
118+ Just x -> pure x
119+ withLock = withResource (atomically (newTSem 1)) (const (return ()))
120+ withServer sem f = bracket_ (atomically (waitTSem sem)) (atomically (signalTSem sem)) $ do
121+ receivedMessages <- atomically newTQueue
122+ withAsync (startServer receivedMessages) $ \_async -> f receivedMessages
123+ testAPIToken = "bigSECRET"
124+ genericLog message = Item
125+ { _itemApp = "katip-logzio"
126+ , _itemEnv = "test"
127+ , _itemSeverity = InfoS
128+ , _itemThread = ThreadIdText "42"
129+ , _itemHost = "localhost"
130+ , _itemProcess = 111
131+ , _itemPayload = ()
132+ , _itemMessage = message
133+ , _itemTime = mkUTCTime 2019 1 2 3 4 5
134+ , _itemNamespace = "test"
135+ , _itemLoc = Nothing
136+ }
137+ startServer receivedMessages = Scotty.scottyOpts serverOpts $ do
138+ Scotty.post "/" $ do
139+ token <- APIToken <$> Scotty.param "token"
140+ if token == testAPIToken
141+ then do
142+ bodyLines <- LBS8.lines <$> Scotty.body
143+ receivedLogs <- mapM (either error pure . A.eitherDecode) bodyLines
144+ let requestLog = RequestLog
145+ { requestLog_token = token
146+ , requestLog_logs = receivedLogs
147+ }
148+ liftIO (atomically (writeTQueue receivedMessages requestLog))
149+ else Scotty.status HTypes.unauthorized401
150+ serverOpts = Scotty.Options
151+ { Scotty.verbose = 0
152+ , Scotty.settings = Warp.setPort 1337 Warp.defaultSettings
153+ }
154+ withScribe modConfig =
155+ bracket (mkLogzIOScribe (modConfig scribeConfig) (const (pure True)) V3) scribeFinalizer
156+ scribeConfig = LogzIOScribeConfiguration
157+ { logzIOScribeConfiguration_bufferItems = 5
158+ , logzIOScribeConfiguration_bufferTimeout = 10
159+ , logzIOScribeConfiguration_scheme = HTTP
160+ , logzIOScribeConfiguration_host = Host "127.0.0.1"
161+ , logzIOScribeConfiguration_port = Port 1337
162+ , logzIOScribeConfiguration_token = testAPIToken
163+ , logzIOScribeConfiguration_retry = defaultRetryPolicy
164+ , logzIOScribeConfiguration_onError = const (pure ())
165+ }
166+
167+
168+
169+data RequestLog = RequestLog
170+ { requestLog_token :: APIToken
171+ , requestLog_logs :: [ReceivedLog]
172+ }
173+
174+
175+-- | Simplified log that only parses the fields we want to assert on
176+data ReceivedLog = ReceivedLog
177+ { receivedLog_message :: Text
178+ }
179+
180+
181+instance FromJSON ReceivedLog where
182+ parseJSON = withObject "ReceivedLog" $ \o -> do
183+ message <- o .: "message"
184+ pure $ ReceivedLog
185+ { receivedLog_message = message
186+ }
187+
188+
189+-------------------------------------------------------------------------------
190+bufferItemTests :: TestTree
191+bufferItemTests = testGroup "bufferItem"
192+ [ testProperty "0 or negative maximum items always flushes immediately" $ property $ do
193+ item <- forAllWith showSimplePayloadItem genSimplePayloadItem
194+ verbosity <- forAll genVerbosity
195+ maxItems <- forAll (Gen.int (Range.linear minBound 0))
196+ bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
197+ let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
198+ assertFlushNow logAction
199+ , testProperty "never buffers too many items" $ property $ do
200+ item <- forAllWith showSimplePayloadItem genSimplePayloadItem
201+ verbosity <- forAll genVerbosity
202+ maxItems <- forAll (Gen.int (Range.linear 0 100))
203+ bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
204+ let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
205+ case logAction of
206+ Buffered newBuffer -> HH.assert (bulkBuffer_itemCount newBuffer <= maxItems)
207+ _ -> pure ()
208+ , testProperty "never produces too many bytes" $ property $ do
209+ item <- forAllWith showSimplePayloadItem genSimplePayloadItem
210+ verbosity <- forAll genVerbosity
211+ maxItems <- forAll (Gen.int (Range.linear 0 100))
212+ bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
213+ let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
214+ case logAction of
215+ Buffered newBuffer -> HH.assert (bulkBuffer_bytesUsed newBuffer <= reducedMaxPayloadBytes)
216+ _ -> pure ()
217+ , testProperty "never flushes more than 1 item" $ property $ do
218+ item <- forAllWith showSimplePayloadItem genSimplePayloadItem
219+ verbosity <- forAll genVerbosity
220+ maxItems <- forAll (Gen.int (Range.linear minBound 0))
221+ bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
222+ let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
223+ case logAction of
224+ FlushNow _ newBuffer -> bulkBuffer_itemCount newBuffer === 1
225+ _ -> pure ()
226+ ]
227+ where
228+ assertFlushNow (FlushNow _ _) = pure ()
229+ assertFlushNow (Buffered _) = fail ("Expceted FlushNow but got a Buffered")
230+ inspectBulkBuffer (BulkBuffer used _ itemCount) =
231+ "BulkBuffer (" <> show used <> ") ... " <> show itemCount
232+
233+
234+-------------------------------------------------------------------------------
235+-- | Reduced max log line length for more space and time efficient tests
236+reducedMaxLogLineLength :: Bytes
237+reducedMaxLogLineLength = Bytes (bytes maxLogLineLength `div` limitScalingFactor)
238+
239+
240+reducedMaxPayloadBytes :: Bytes
241+reducedMaxPayloadBytes = Bytes (bytes maxPayloadBytes `div` limitScalingFactor)
242+
243+
244+limitScalingFactor :: Int64
245+limitScalingFactor = 10
246+
247+-------------------------------------------------------------------------------
248+showSimplePayloadItem :: Item SimpleLogPayload -> String
249+showSimplePayloadItem item =
250+ show (item { _itemPayload = [(k, toJSON v) | (k, AnyLogPayload v) <- unSimpleLogPayload (_itemPayload item)]})
251+
252+
253+-------------------------------------------------------------------------------
254+renderLineTruncatedTests :: TestTree
255+renderLineTruncatedTests = testGroup "renderLineTruncated"
256+ [ testProperty "always produces an accurate byte count" $ property $ do
257+ item <- forAllWith showSimplePayloadItem genSimplePayloadItem
258+ verbosity <- forAll genVerbosity
259+ let (builder, Bytes byteCount) = renderLineTruncated' reducedMaxLogLineLength verbosity item
260+ builderLength builder === byteCount
261+ , testProperty "always produces a payload smaller than the line limit" $ property $ do
262+ let tinyMaxLineLength = 1024 -- set a really low one for speed
263+ let lowerBound = fromIntegral (bytes tinyMaxLineLength - 100)
264+ let upperBound = fromIntegral (bytes tinyMaxLineLength + 100)
265+ bigMessage <- forAll (genLogStr' (Range.linear lowerBound upperBound))
266+ item <- (\item -> item { _itemMessage = bigMessage}) <$> forAllWith showSimplePayloadItem genSimplePayloadItem
267+ verbosity <- forAll genVerbosity
268+ let (_, byteCount) = renderLineTruncated' tinyMaxLineLength verbosity item
269+ HH.assert (byteCount <= reducedMaxLogLineLength)
270+ ]
271+
272+
273+-------------------------------------------------------------------------------
274+builderLength :: BB.Builder -> Int64
275+builderLength = LBS.length . BB.toLazyByteString
276+
277+-------------------------------------------------------------------------------
278+genVerbosity :: Gen Verbosity
279+genVerbosity = Gen.enumBounded
280+
281+
282+-------------------------------------------------------------------------------
283+genBulkBuffer :: Gen BulkBuffer
284+genBulkBuffer = do
285+ bytesUsed <- genBytes
286+ -- generating a large bytestring string with hedghog seems quite slow, instead we'll generate a smaller string and replicate and merge it
287+ payloadMultiplier <- Gen.int (Range.linear 1 100)
288+ payloadSegment <- genBuilder (Range.linear 0 (round (toRational (bytes reducedMaxPayloadBytes) * 0.05)))
289+ let payload = mconcat (replicate payloadMultiplier payloadSegment)
290+ itemCount <- Gen.int (Range.linear 0 maxBound)
291+ pure $ BulkBuffer
292+ { bulkBuffer_bytesUsed = bytesUsed
293+ , bulkBuffer_payload = payload
294+ , bulkBuffer_itemCount = itemCount
295+ }
296+
297+
298+-------------------------------------------------------------------------------
299+genBytes :: Gen Bytes
300+genBytes = Bytes <$> Gen.int64 (Range.linear 0 maxBound)
301+
302+
303+-------------------------------------------------------------------------------
304+genBuilder :: Range Int -> Gen BB.Builder
305+genBuilder size = BB.byteString <$> Gen.bytes size
306+
307+
308+-------------------------------------------------------------------------------
309+genSimplePayloadItem :: Gen (Item SimpleLogPayload)
310+genSimplePayloadItem = do
311+ app <- genNamespace
312+ env <- genEnvironment
313+ severity <- genSeverity
314+ thread <- genThreadIdText
315+ host <- genHostName
316+ process <- genProcessID
317+ payload <- genSimpleLogPayload
318+ message <- genLogStr
319+ time <- genUTCTime
320+ namespace <- genNamespace
321+ loc <- Gen.maybe genLoc
322+ pure $ Item
323+ { _itemApp = app
324+ , _itemEnv = env
325+ , _itemSeverity = severity
326+ , _itemThread = thread
327+ , _itemHost = host
328+ , _itemProcess = process
329+ , _itemPayload = payload
330+ , _itemMessage = message
331+ , _itemTime = time
332+ , _itemNamespace = namespace
333+ , _itemLoc = loc
334+ }
335+
336+
337+genNamespace :: Gen Namespace
338+genNamespace = Namespace <$> Gen.list (Range.linear 0 20) genSmallText
339+
340+
341+genEnvironment :: Gen Environment
342+genEnvironment = Environment <$> genSmallText
343+
344+
345+genSeverity :: Gen Severity
346+genSeverity = Gen.enumBounded
347+
348+
349+genThreadIdText :: Gen ThreadIdText
350+genThreadIdText = ThreadIdText <$> genSmallText
351+
352+
353+genHostName :: Gen HostName
354+genHostName = genSmallString
355+
356+
357+genProcessID :: Gen ProcessID
358+genProcessID = Gen.enumBounded
359+
360+
361+genLogStr :: Gen LogStr
362+genLogStr = genLogStr' (Range.linear 0 1024)
363+
364+
365+genLogStr' :: Range Int -> Gen LogStr
366+genLogStr' sizeRange = ls <$> Gen.text sizeRange Gen.unicode
367+
368+
369+genUTCTime :: Gen UTCTime
370+genUTCTime = posixSecondsToUTCTime . fromIntegral
371+ <$> Gen.int (Range.linear 0 maxBound)
372+
373+
374+genLoc :: Gen Loc
375+genLoc = Loc
376+ <$> genSmallString
377+ <*> genSmallString
378+ <*> genSmallString
379+ <*> genCharPos
380+ <*> genCharPos
381+ where
382+ genCharPos = (,)
383+ <$> Gen.int (Range.linear 0 maxBound)
384+ <*> Gen.int (Range.linear 0 maxBound)
385+
386+
387+genSimpleLogPayload :: Gen SimpleLogPayload
388+genSimpleLogPayload = SimpleLogPayload
389+ <$> Gen.list (Range.linear 0 20) genPair
390+ where
391+ genPair = (,)
392+ <$> genSmallText
393+ <*> genAnyLogPayload
394+
395+
396+genAnyLogPayload :: Gen AnyLogPayload
397+genAnyLogPayload = AnyLogPayload
398+ <$> genValue
399+
400+
401+genValue :: Gen Value
402+genValue = Gen.recursive Gen.choice nonRecursiveGens recursiveGens
403+ where
404+ nonRecursiveGens =
405+ [ pure A.Null
406+ , A.String <$> genSmallText
407+ , A.Number <$> genScientific
408+ , A.Bool <$> Gen.bool
409+ ]
410+ recursiveGens =
411+ [ A.Object <$> genObject
412+ , A.Array <$> genArray
413+ ]
414+ genObject = HM.fromList <$> Gen.list (Range.linear 0 20) genPair
415+ where
416+ genPair = (,) <$> genSmallText <*> genValue
417+ genArray = V.fromList <$>
418+ Gen.list (Range.linear 0 20) genValue
419+
420+
421+
422+genScientific :: Gen Scientific
423+genScientific = scientific
424+ <$> Gen.integral (Range.linear (-999999) 999999)
425+ <*> Gen.int (Range.linear (-999) (999))
426+
427+
428+genSmallText :: Gen Text
429+genSmallText = Gen.text (Range.linear 0 128) Gen.unicode
430+
431+
432+genSmallString :: Gen String
433+genSmallString = Gen.string (Range.linear 0 128) Gen.unicode
434+
435+
436+-------------------------------------------------------------------------------
437+mkUTCTime :: Integer -> Int -> Int -> DiffTime -> DiffTime -> DiffTime -> UTCTime
438+mkUTCTime y m d hr mn s = UTCTime day dt
439+ where
440+ day = mkDay y m d
441+ dt = s + 60 * mn + 60 * 60 * hr
442+
443+
444+-------------------------------------------------------------------------------
445+mkDay :: Integer -> Int -> Int -> Day
446+mkDay y m d = day
447+ where
448+ Just day = fromGregorianValid y m d
katip-logzio/test/Main.hs+3−2
@@ -6,6 +6,7 @@ module Main
66-------------------------------------------------------------------------------
77import Test.Tasty
88-------------------------------------------------------------------------------
9+import qualified Katip.Tests.Scribes.LogzIO.HTTPS
910-------------------------------------------------------------------------------
1011
1112
@@ -16,6 +17,6 @@ main = defaultMain tests
1617
1718-------------------------------------------------------------------------------
1819tests :: TestTree
19−tests = testGroup "katip-datadog"
20− [
20+tests = testGroup "katip-logzio"
21+ [ Katip.Tests.Scribes.LogzIO.HTTPS.tests
2122 ]
katip/src/Katip/Core.hs+1−1
@@ -139,7 +139,7 @@ data Severity
139139-- - 'V3' implies the maximum amount of payload information.
140140-- - Anything in between is left to the discretion of the developer.
141141data Verbosity = V0 | V1 | V2 | V3
142− deriving (Eq, Ord, Show, Read, Generic, Enum)
142+ deriving (Eq, Ord, Show, Read, Generic, Enum, Bounded)
143143
144144
145145-------------------------------------------------------------------------------