packages feed

log 0.5.3 → 0.5.4

raw patch · 14 files changed

+576/−99 lines, 14 filesdep +http-typesdep +logdep +processdep ~aesondep ~basedep ~bloodhoundnew-uploaderPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: http-types, log, process, random, semigroups, tasty, tasty-hunit

Dependency ranges changed: aeson, base, bloodhound, exceptions, time

API changes (from Hackage documentation)

- Log.Internal.Logger: Logger :: !(LogMessage -> IO ()) -> !(STM ()) -> ![IORef ()] -> Logger
- Log.Internal.Logger: [loggerFinalizers] :: Logger -> ![IORef ()]
- Log.Internal.Logger: [loggerWaitForWrite] :: Logger -> !(STM ())
- Log.Internal.Logger: [loggerWriteMessage] :: Logger -> !(LogMessage -> IO ())
- Log.Internal.Logger: data Logger
- Log.Internal.Logger: execLogger :: Logger -> LogMessage -> IO ()
- Log.Internal.Logger: instance GHC.Base.Monoid Log.Internal.Logger.Logger
- Log.Internal.Logger: waitForLogger :: Logger -> IO ()
+ Log.Backend.ElasticSearch: defaultElasticSearchConfig :: ElasticSearchConfig
+ Log.Backend.ElasticSearch: withElasticSearchLogger :: ElasticSearchConfig -> IO Word32 -> (Logger -> IO r) -> IO r
+ Log.Backend.PostgreSQL: withPgLogger :: ConnectionSourceM IO -> (Logger -> IO r) -> IO r
+ Log.Backend.StandardOutput: withSimpleStdOutLogger :: (Logger -> IO r) -> IO r
+ Log.Backend.StandardOutput.Bulk: bulkStdoutLogger :: IO Logger
+ Log.Backend.StandardOutput.Bulk: withBulkStdOutLogger :: (Logger -> IO r) -> IO r
+ Log.Logger: shutdownLogger :: Logger -> IO ()
- Log.Class: currentTime :: m UTCTime
+ Log.Class: currentTime :: MonadTime m => m UTCTime
- Log.Logger: mkBulkLogger :: Text -> ([LogMessage] -> IO ()) -> IO Logger
+ Log.Logger: mkBulkLogger :: Text -> ([LogMessage] -> IO ()) -> IO () -> IO Logger

Files

log.cabal view
@@ -1,8 +1,9 @@ name:                log-version:             0.5.3+version:             0.5.4 synopsis:            Structured logging solution with multiple backends -description:         A library that provides a way to record structured log messages with multiple backends.+description:         A library that provides a way to record structured+                     log messages with multiple backends.                      .                      Supported backends:                      .@@ -15,21 +16,26 @@ homepage:            https://github.com/scrive/log license:             BSD3 license-file:        LICENSE-author:              Scrive-maintainer:          Andrzej Rybczak <andrzej@rybczak.net>, Jonathan Jouty <jonathan@scrive.com>+author:              Scrive AB+maintainer:          Andrzej Rybczak <andrzej@rybczak.net>,+                     Jonathan Jouty <jonathan@scrive.com>,+                     Mikhail Glushenkov <mikhail@scrive.com>+copyright:           Scrive AB category:            System build-type:          Simple cabal-version:       >=1.10+tested-with:         GHC == 7.8.4, GHC == 7.10.3, GHC == 8.0.1  Source-repository head   Type:     git-  Location: https://github.com/scrive/log+  Location: https://github.com/scrive/log.git  library   exposed-modules:     Log,                        Log.Backend.ElasticSearch,                        Log.Backend.PostgreSQL,                        Log.Backend.StandardOutput,+                       Log.Backend.StandardOutput.Bulk,                        Log.Class,                        Log.Data,                        Log.Internal.Logger,@@ -52,6 +58,7 @@                        monad-time >= 0.2,                        mtl,                        old-locale,+                       semigroups,                        split,                        stm >=2.4,                        text,@@ -80,3 +87,52 @@                     , ScopedTypeVariables                     , TypeFamilies                     , UndecidableInstances++test-suite log-test+  type:               exitcode-stdio-1.0+  build-depends:      aeson,+                      base,+                      bloodhound,+                      bytestring,+                      http-client,+                      http-types,+                      log,+                      random,+                      tasty,+                      tasty-hunit,+                      time,+                      text+  hs-source-dirs:     test+  main-is:            Test.hs+  other-modules:      Test.ElasticSearch+  ghc-options:        -Wall -threaded+  default-language:   Haskell2010+  default-extensions: BangPatterns+                    , OverloadedStrings+                    , RecordWildCards++test-suite log-test-integration+  type:               exitcode-stdio-1.0+  build-depends:      aeson,+                      base,+                      bloodhound,+                      bytestring,+                      exceptions,+                      http-client,+                      http-types,+                      log,+                      process,+                      random,+                      tasty,+                      tasty-hunit,+                      time,+                      text,+                      transformers+  hs-source-dirs:     test+  main-is:            IntegrationTest.hs+  other-modules:      Test.ElasticSearch+  ghc-options:        -Wall -threaded+  default-language:   Haskell2010+  default-extensions: BangPatterns+                    , OverloadedStrings+                    , RecordWildCards
src/Log.hs view
@@ -1,3 +1,4 @@+-- | Structured logging solution with multiple backends. module Log (     module Log.Class   , module Log.Data
src/Log/Backend/ElasticSearch.hs view
@@ -1,6 +1,10 @@+-- | Elasticsearch logging back-end. module Log.Backend.ElasticSearch (     ElasticSearchConfig(..)+  , defaultElasticSearchConfig+  , withElasticSearchLogger   , elasticSearchLogger+   ) where  import Control.Applicative@@ -14,8 +18,8 @@ import Data.Aeson.Encode.Pretty import Data.Bits import Data.IORef-import Data.Monoid import Data.Monoid.Utils+import Data.Semigroup import Data.Time import Data.Time.Clock.POSIX import Data.Word@@ -33,19 +37,50 @@ import qualified Data.Traversable as F import qualified Data.Vector as V +-- | Configuration for the Elasticsearch 'Logger'. See+-- <https://www.elastic.co/guide/en/elasticsearch/reference/current/glossary.html>+-- for the explanation of terms. data ElasticSearchConfig = ElasticSearchConfig {-    esServer  :: !T.Text-  , esIndex   :: !T.Text-  , esMapping :: !T.Text+    esServer  :: !T.Text -- ^ Elasticsearch server address.+  , esIndex   :: !T.Text -- ^ Elasticsearch index name.+  , esMapping :: !T.Text -- ^ Elasticsearch mapping name.   } deriving (Eq, Show) +-- | Sensible defaults for 'ElasticSearchConfig'.+defaultElasticSearchConfig :: ElasticSearchConfig+defaultElasticSearchConfig = ElasticSearchConfig {+  esServer  = "http://localhost:9200",+  esIndex   = "logs",+  esMapping = "log"+  }++ ----------------------------------------+-- | Create an 'elasticSearchLogger' for the duration of the given+-- action, and shut it down afterwards, making sure that all buffered+-- messages are actually written to the Elasticsearch store.+withElasticSearchLogger :: ElasticSearchConfig -> IO Word32 -> (Logger -> IO r)+                        -> IO r+withElasticSearchLogger conf randGen act = do+  logger <- elasticSearchLogger conf randGen+  (act logger) `finally` (do { waitForLogger logger; shutdownLogger logger; }) -elasticSearchLogger :: ElasticSearchConfig -> IO Word32 -> IO Logger+{-# DEPRECATED elasticSearchLogger "Use 'withElasticSearchLogger' instead!" #-}++-- | Start an asynchronous logger thread that stores messages using+-- Elasticsearch.+--+-- Please use 'withElasticSearchLogger' instead, which is more+-- exception-safe (see the note attached to 'mkBulkLogger').+elasticSearchLogger ::+  ElasticSearchConfig -- ^ Configuration.+  -> IO Word32        -- ^ Generate a random 32-bit word for use in+                      -- document IDs.+  -> IO Logger elasticSearchLogger ElasticSearchConfig{..} genRandomWord = do   checkElasticSearchConnection   indexRef <- newIORef $ IndexName T.empty-  mkBulkLogger "ElasticSearch" $ \msgs -> do+  mkBulkLogger "ElasticSearch" (\msgs -> do     now <- getCurrentTime     oldIndex <- readIORef indexRef     -- Bloodhound doesn't support letting ES autogenerate IDs, so let's generate@@ -56,7 +91,7 @@       <$> (littleEndianRep <$> liftIO genRandomWord)       <*> pure (littleEndianRep . floor $ timeToDouble now)     retryOnException . runBH_ $ do-      -- ElasticSearch index names are additionally indexed by date so that each+      -- Elasticsearch index names are additionally indexed by date so that each       -- day is logged to a separate index to make log management easier.       let index = IndexName $ T.concat [               esIndex@@ -77,7 +112,9 @@           void $ createIndex indexSettings index           reply <- putMapping index mapping LogsMapping           when (not $ isSuccess reply) $ do-            error $ "ElasticSearch: error while creating mapping:" <+> T.unpack (T.decodeUtf8 . BSL.toStrict . jsonToBSL $ decodeReply reply)+            error $ "ElasticSearch: error while creating mapping:"+              <+> T.unpack (T.decodeUtf8 . BSL.toStrict . jsonToBSL+                            $ decodeReply reply)         liftIO $ writeIORef indexRef index       let jsonMsgs = V.fromList $ map (toJsonMsg now) $ zip [1..] msgs       reply <- bulk $ V.map (toBulk index baseID) jsonMsgs@@ -95,7 +132,8 @@             guard $ V.length items == V.length jsonMsgs             return (hasErrors, items)       case result of-        Nothing -> liftIO . BSL.putStrLn $ "ElasticSearch: unexpected response:" <+> jsonToBSL replyBody+        Nothing -> liftIO . BSL.putStrLn+          $ "ElasticSearch: unexpected response:" <+> jsonToBSL replyBody         Just (hasErrors, items) -> when hasErrors $ do           -- If any message failed to be inserted because of type mismatch, go           -- back to them, replace their data with elastic search error and put@@ -110,20 +148,29 @@                   ]             return . second (H.adjust modifyData "data") $ jsonMsgs V.! n           -- Attempt to put modified messages and ignore any further errors.-          void $ bulk (V.map (toBulk index baseID) dummyMsgs)+          void $ bulk (V.map (toBulk index baseID) dummyMsgs))+    (elasticSearchSync indexRef)   where     server  = Server esServer     mapping = MappingName esMapping +    elasticSearchSync :: IORef IndexName -> IO ()+    elasticSearchSync indexRef = do+      indexName <- readIORef indexRef+      void . runBH_ $ refreshIndex indexName+     checkElasticSearchConnection :: IO ()     checkElasticSearchConnection = try (void $ runBH_ listIndices) >>= \case-      Left (ex::HttpException) -> error $ "ElasticSearch: unexpected error: " <> show ex <> " (is ElasticSearch server running?)"+      Left (ex::HttpException) -> error $ "ElasticSearch: unexpected error: "+                                  <> show ex+                                  <> " (is ElasticSearch server running?)"       Right () -> return ()      retryOnException :: forall r. IO r -> IO r     retryOnException m = try m >>= \case       Left (ex::SomeException) -> do-        putStrLn $ "ElasticSearch: unexpected error: " <> show ex <> ", retrying in 10 seconds"+        putStrLn $ "ElasticSearch: unexpected error: "+          <> show ex <> ", retrying in 10 seconds"         threadDelay $ 10 * 1000000         retryOnException m       Right result -> return result@@ -137,7 +184,8 @@     jsonToBSL :: Value -> BSL.ByteString     jsonToBSL = encodePretty' defConfig { confIndent = Spaces 2 } -    toJsonMsg :: UTCTime -> (Word32, LogMessage) -> (Word32, H.HashMap T.Text Value)+    toJsonMsg :: UTCTime -> (Word32, LogMessage)+              -> (Word32, H.HashMap T.Text Value)     toJsonMsg now (n, msg) = (n, H.union jMsg $ H.fromList [         ("insertion_order", toJSON n)       , ("insertion_time",  toJSON now)@@ -146,29 +194,31 @@         Object jMsg = toJSON msg      mkDocId :: BS.ByteString -> Word32 -> DocId-    mkDocId baseID insertionOrder = DocId . T.decodeUtf8 . B64.encode $ BS.concat [+    mkDocId baseID insertionOrder = DocId . T.decodeUtf8+                                    . B64.encode $ BS.concat [         baseID       , littleEndianRep insertionOrder       ] -    toBulk :: IndexName -> BS.ByteString -> (Word32, H.HashMap T.Text Value) -> BulkOperation-    toBulk index baseID (n, obj) = BulkIndex index mapping (mkDocId baseID n) $ Object obj+    toBulk :: IndexName -> BS.ByteString -> (Word32, H.HashMap T.Text Value)+           -> BulkOperation+    toBulk index baseID (n, obj) =+      BulkIndex index mapping (mkDocId baseID n) $ Object obj  data LogsMapping = LogsMapping instance ToJSON LogsMapping where   toJSON LogsMapping = object [-      "logs" .= object [-        "properties" .= object [-          "insertion_order" .= object [+    "properties" .= object [+        "insertion_order" .= object [             "type" .= ("integer"::T.Text)           ]         , "insertion_time" .= object [             "type"   .= ("date"::T.Text)-          , "format" .= ("strict_date_time"::T.Text)+          , "format" .= ("date_time"::T.Text)           ]         , "time" .= object [             "type"   .= ("date"::T.Text)-          , "format" .= ("strict_date_time"::T.Text)+          , "format" .= ("date_time"::T.Text)           ]         , "domain" .= object [             "type" .= ("string"::T.Text)@@ -183,7 +233,6 @@             "type" .= ("string"::T.Text)           ]         ]-      ]     ]  ----------------------------------------
src/Log/Backend/PostgreSQL.hs view
@@ -1,4 +1,5 @@-module Log.Backend.PostgreSQL (pgLogger) where+-- | PostgreSQL logging back-end.+module Log.Backend.PostgreSQL (pgLogger, withPgLogger) where  import Control.Applicative import Control.Concurrent@@ -6,8 +7,8 @@ import Control.Monad.State.Lazy import Data.Aeson import Data.List.Split-import Data.Monoid import Data.Monoid.Utils+import Data.Semigroup import Data.String import Data.Typeable import Database.PostgreSQL.PQTypes@@ -25,19 +26,39 @@ newtype InvalidEncodingRecoveryAttempt = Attempt Int   deriving Enum --- | Create logger that inserts log messages into PostgreSQL database.+-- | Create a 'pgLogger' for the duration of the given action, and+-- shut it down afterwards, making sure that all buffered messages are+-- actually written to the DB.+withPgLogger :: ConnectionSourceM IO -> (Logger -> IO r) -> IO r+withPgLogger cs act = do+  logger <- pgLogger cs+  (act logger) `finally` (do { waitForLogger logger; shutdownLogger logger; })++{-# DEPRECATED pgLogger "Use 'withPgLogger' instead!" #-}++-- | Start an asynchronous logger thread that inserts log messages+-- into a PostgreSQL database.+--+-- Please use 'withPglogger' instead, which is more exception-safe+-- (see the note attached to 'mkBulkLogger'). pgLogger :: ConnectionSourceM IO -> IO Logger pgLogger cs = mkBulkLogger loggerName-            $ mapM_ (serialize $ Attempt 1) . chunksOf 1000+              (mapM_ (serialize $ Attempt 1) . chunksOf 1000)+              (return ())   where     loggerName :: IsString s => s     loggerName = "PostgreSQL"      sqlInsertLog :: SQL-    sqlInsertLog = "INSERT INTO logs (insertion_time, insertion_order, time, level, component, domain, message, data) VALUES"+    sqlInsertLog = "INSERT INTO logs "+      <+> "(insertion_time, insertion_order, time, level, component,"+      <+> " domain, message, data) VALUES"      serialize :: InvalidEncodingRecoveryAttempt -> [LogMessage] -> IO ()-    serialize !attempt msgs = runDBT cs ts (runSQL_ $ sqlInsertLog <+> mintercalate ", " (map sqlifyMessage $ zip [1..] msgs)) `catches` [+    serialize !attempt msgs = runDBT cs ts+      (runSQL_ $ sqlInsertLog+       <+> mintercalate ", " (map sqlifyMessage $ zip [1..] msgs))+      `catches` [         -- Propagate base async exceptions thrown by the runtime system.         Handler $ \(e::AsyncException) -> throwIO e       , Handler $ \(e::SomeException) -> case fromException e of@@ -49,13 +70,16 @@               -- serialization), then this error occurs only when any of the               -- strings we want to serialize contains NULL bytes. In such               -- case we scan the logs and replace each NULL with "\0".-              putStrLn $ loggerName ++ ": couldn't serialize logs due to character encoding error \"" ++ qeMessagePrimary qe ++ "\", removing NULL bytes and retrying"+              putStrLn $ loggerName+                ++ ": couldn't serialize logs due to character encoding error \""+                ++ qeMessagePrimary qe ++ "\", removing NULL bytes and retrying"               serialize (succ attempt) $ map (\msg ->                 -- If any text inside the message had NULL bytes,                 -- add acknowledgment of that fact to its data.                 case runState (mapTexts removeNULLs msg) False of                   (newMsg, True) -> newMsg {-                    lmData = lmData newMsg `addPair` ("_log", "NULL bytes were escaped")+                    lmData = lmData newMsg+                             `addPair` ("_log", "NULL bytes were escaped")                   }                   (_, False) -> msg) msgs             Attempt 2 -> do@@ -63,17 +87,28 @@               -- a minute. If the error is still happening after removal               -- of NULL bytes, go through each message and encode all               -- texts as base64, effectively transforming them into ASCII.-              putStrLn $ loggerName ++ ": couldn't serialize logs due to character encoding error \"" ++ qeMessagePrimary qe ++ "\" after NULL bytes were removed, encoding all texts in the problematic batch as base64 to make them ASCII"+              putStrLn $ loggerName+                ++ ": couldn't serialize logs due to character encoding error \""+                ++ qeMessagePrimary qe+                ++ "\" after NULL bytes were removed, encoding all texts"+                ++ " in the problematic batch as base64 to make them ASCII"               serialize (succ attempt) $ map (\msg ->                 let newMsg = runIdentity $ mapTexts convertBase64 msg                  in newMsg {-                  lmData = lmData newMsg `addPair` ("_log", "Texts encoded as base64")+                  lmData = lmData newMsg+                    `addPair` ("_log", "Texts encoded as base64")                 }) msgs             Attempt _ -> do               -- This can't happen, all texts are ASCII now.-              putStrLn $ loggerName ++ ": impossible happened (>2 attempt failed because of character encoding error \"" ++ qeMessagePrimary qe ++ "\" even though all texts are ASCII), skipping the batch"+              putStrLn $ loggerName+                ++ ": impossible happened "+                ++ "(>2 attempt failed because of character encoding error \""+                ++ qeMessagePrimary qe+                ++ "\" even though all texts are ASCII), skipping the batch"         _ -> do-          putStrLn $ loggerName ++ ": couldn't serialize logs:" <+> show e ++ ", retrying in 10 seconds"+          putStrLn $ loggerName+            ++ ": couldn't serialize logs:"+            <+> show e ++ ", retrying in 10 seconds"           threadDelay $ 10 * 1000000           -- Do not increment the attempt here, it's used to           -- track invalid encoding recovery attempts only.
src/Log/Backend/StandardOutput.hs view
@@ -1,23 +1,38 @@+-- | Stdout logging back-end. module Log.Backend.StandardOutput (     simpleStdoutLogger   , stdoutLogger+  , withSimpleStdOutLogger   ) where  import Prelude+import Control.Exception import qualified Data.Text.IO as T+import System.IO  import Log.Data import Log.Internal.Logger import Log.Logger +-- | Create a 'simpleStdoutlogger' for the duration of the given+-- action, making sure that stdout is flushed afterwards.+withSimpleStdOutLogger :: (Logger -> IO r) -> IO r+withSimpleStdOutLogger act = do+  logger <- stdoutLogger+  (act logger) `finally` (do { waitForLogger logger; shutdownLogger logger; })++{-# DEPRECATED simpleStdoutLogger "Use 'withSimpleStdoutLogger'" #-}+ -- | Simple, synchronous logger that prints messages to standard output. simpleStdoutLogger :: Logger simpleStdoutLogger = Logger {     loggerWriteMessage = T.putStrLn . showLogMessage Nothing-  , loggerWaitForWrite = return ()-  , loggerFinalizers   = []+  , loggerWaitForWrite = hFlush stdout+  , loggerShutdown     = return ()   } --- | Create logger that prints messages to standard output.+{-# DEPRECATED stdoutLogger "Use 'withSimpleStdoutLogger'" #-}++-- | Create a logger that prints messages to standard output. stdoutLogger :: IO Logger stdoutLogger = mkLogger "stdout" $ T.putStrLn . showLogMessage Nothing
+ src/Log/Backend/StandardOutput/Bulk.hs view
@@ -0,0 +1,32 @@+-- | Bulk stdout logging back-end, useful mainly for testing.+module Log.Backend.StandardOutput.Bulk (+    withBulkStdOutLogger,+    bulkStdoutLogger+  ) where++import Control.Exception+import Prelude+import qualified Data.Text.IO as T++import Log.Data+import Log.Logger++-- | Create a 'bulkStdoutLogger' for the duration of the given action,+-- and shut it down afterwards, making sure that all buffered messages+-- are actually written to stdout.+withBulkStdOutLogger :: (Logger -> IO r) -> IO r+withBulkStdOutLogger act = do+  logger <- bulkStdoutLogger+  (act logger) `finally` (do { waitForLogger logger; shutdownLogger logger; })++{-# DEPRECATED bulkStdoutLogger "Use 'withBulkStdOutLogger' instead!" #-}++-- | Start an asynchronous logger thread that prints messages to+-- standard output.+--+-- Please use 'withBulkStdOutLogger'' instead, which is more exception-safe+-- (see the note attached to 'mkBulkLogger').+bulkStdoutLogger :: IO Logger+bulkStdoutLogger = mkBulkLogger "stdout-bulk"+                   (mapM_ $ T.putStrLn . showLogMessage Nothing)+                   (return ())
src/Log/Class.hs view
@@ -1,3 +1,4 @@+-- | The 'MonadLog' type class of monads with logging capabilities. {-# OPTIONS_GHC -fno-warn-deprecated-flags #-} {-# LANGUAGE OverlappingInstances #-} module Log.Class (@@ -23,10 +24,20 @@  import Log.Data --- | Represents the family of monads with logging capabilities.+-- | Represents the family of monads with logging capabilities. Each+-- 'MonadLog' carries with it some associated state (the logging+-- environment) that can be modified locally with 'localData' and+-- 'localDomain'. class MonadTime m => MonadLog m where-  logMessage  :: UTCTime -> LogLevel -> T.Text -> Value -> m ()+  -- | Write a message to the log.+  logMessage  :: UTCTime  -- ^ Time of the event.+              -> LogLevel -- ^ Log level.+              -> T.Text   -- ^ Log message.+              -> Value    -- ^ Additional data associated with the message.+              -> m ()+  -- | Extend the additional data associated with each log message locally.   localData   :: [Pair] -> m a -> m a+  -- | Extend the current application domain locally.   localDomain :: T.Text -> m a -> m a  -- | Generic, overlapping instance.@@ -45,26 +56,37 @@  ---------------------------------------- +-- | Log a message and its associated data using current time as the+-- event time and the 'LogAttention' log level. logAttention :: MonadLog m => T.Text -> Value -> m () logAttention = logNow LogAttention +-- | Log a message and its associated data using current time as the+-- event time and the 'LogInfo' log level. logInfo :: MonadLog m => T.Text -> Value -> m () logInfo = logNow LogInfo +-- | Log a message and its associated data using current time as the+-- event time and the 'LogTrace' log level. logTrace :: MonadLog m => T.Text -> Value -> m () logTrace = logNow LogTrace +-- | Like 'logAttention', but without any additional associated data. logAttention_ :: MonadLog m => T.Text -> m () logAttention_ = (`logAttention` emptyObject) +-- | Like 'logInfo', but without any additional associated data. logInfo_ :: MonadLog m => T.Text -> m () logInfo_ = (`logInfo` emptyObject) +-- | Like 'logTrace', but without any additional associated data. logTrace_ :: MonadLog m => T.Text -> m () logTrace_ = (`logTrace` emptyObject)  ---------------------------------------- +-- | Write a log message using the given log level, message text and+-- additional data. Current time is used as the event time. logNow :: MonadLog m => LogLevel -> T.Text -> Value -> m () logNow level message data_ = do   time <- currentTime
src/Log/Data.hs view
@@ -1,3 +1,4 @@+-- | Basic data types used throughout the package. module Log.Data (     LogLevel(..)   , showLogLevel@@ -24,7 +25,8 @@ readLogLevel "attention" = LogAttention readLogLevel "info"      = LogInfo readLogLevel "trace"     = LogTrace-readLogLevel level       = error $ "readLogLevel: unknown level: " ++ T.unpack level+readLogLevel level       = error $ "readLogLevel: unknown level: "+                           ++ T.unpack level  showLogLevel :: LogLevel -> T.Text showLogLevel LogAttention = "attention"@@ -40,7 +42,7 @@   lmComponent :: !T.Text   -- | Aplication log domain. , lmDomain    :: ![T.Text]-  -- | Time of log.+  -- | Time of the logged event. , lmTime      :: !UTCTime   -- | Log level. , lmLevel     :: !LogLevel@@ -50,7 +52,10 @@ , lmData      :: !Value } deriving (Eq, Show) -showLogMessage :: Maybe UTCTime -> LogMessage -> T.Text+-- | Render a 'LogMessage' to 'Text'.+showLogMessage :: Maybe UTCTime -- ^ The time that message was added to the log.+               -> LogMessage    -- ^ The actual message.+               -> T.Text showLogMessage mInsertionTime LogMessage{..} = T.concat $ [     T.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" lmTime   , case mInsertionTime of
src/Log/Internal/Logger.hs view
@@ -1,40 +1,61 @@+-- | The 'Logger' type - implementation details.+{-# OPTIONS_HADDOCK hide #-} module Log.Internal.Logger (     Logger(..)   , execLogger   , waitForLogger+  , shutdownLogger   ) where -import Control.Concurrent.STM-import Data.IORef-import Data.Monoid+import Data.Semigroup import Prelude  import Log.Data --- | Data type representing logger.+-- | An object used for communication with a logger thread that+-- outputs 'LogMessage's using e.g. PostgreSQL, Elasticsearch or+-- stdout (depending on the back-end chosen). data Logger = Logger {-  loggerWriteMessage :: !(LogMessage -> IO ())-, loggerWaitForWrite :: !(STM ())-, loggerFinalizers   :: ![IORef ()]+  loggerWriteMessage :: !(LogMessage -> IO ()) -- ^ Output a 'LogMessage'.+, loggerWaitForWrite :: !(IO ())+                     -- ^ Wait for the logger to output all messages+                     -- in its input queue (in the case logging is+                     -- done asynchronously).+, loggerShutdown     :: !(IO ())+                     -- ^ Kill the logger thread. Subsequent attempts+                     -- to write messages to the logger will raise an+                     -- exception. }  -- | Execute logger to serialize a 'LogMessage'. execLogger :: Logger -> LogMessage -> IO () execLogger Logger{..} = loggerWriteMessage --- | Wait until logs stored in an internal queue are serialized.+-- | Wait until all 'LogMessage's stored in the internal queue are+-- serialized. waitForLogger :: Logger -> IO ()-waitForLogger Logger{..} = atomically loggerWaitForWrite+waitForLogger Logger{..} = loggerWaitForWrite --- | Composition of 'Logger' objects.-instance Monoid Logger where-  mempty = Logger (const $ return ()) (return ()) []-  l1 `mappend` l2 = Logger {+-- | Shutdown the logger thread associated with this 'Logger'+-- object. Subsequent attempts to write messages via this 'Logger'+-- will result in an exception.+shutdownLogger :: Logger -> IO ()+shutdownLogger Logger{..} = loggerShutdown++instance Semigroup Logger where+  l1 <> l2 = Logger {     loggerWriteMessage = \msg -> do       loggerWriteMessage l1 msg       loggerWriteMessage l2 msg   , loggerWaitForWrite = do       loggerWaitForWrite l1       loggerWaitForWrite l2-  , loggerFinalizers = loggerFinalizers l1 ++ loggerFinalizers l2+  , loggerShutdown     = do+      loggerShutdown l1+      loggerShutdown l2   }++-- | Composition of 'Logger' objects.+instance Monoid Logger where+  mempty  = Logger (const $ return ()) (return ()) (return ())+  mappend = (<>)
src/Log/Logger.hs view
@@ -1,9 +1,11 @@+-- | The 'Logger' type of logging back-ends. module Log.Logger (     Logger   , mkLogger   , mkBulkLogger   , execLogger   , waitForLogger+  , shutdownLogger   ) where  import Control.Applicative@@ -11,8 +13,7 @@ import Control.Concurrent.STM import Control.Exception import Control.Monad-import Data.IORef-import Data.Monoid+import Data.Semigroup import Prelude import qualified Data.Text as T import qualified Data.Text.IO as T@@ -20,19 +21,65 @@ import Log.Data import Log.Internal.Logger --- | Make 'Logger' that consumes one queued message at a time.+-- | Start a logger thread that consumes one queued message at a time. mkLogger :: T.Text -> (LogMessage -> IO ()) -> IO Logger-mkLogger = mkLoggerImpl-  newTQueueIO isEmptyTQueue readTQueue writeTQueue $ return ()+mkLogger name exec = mkLoggerImpl+  newTQueueIO isEmptyTQueue readTQueue writeTQueue (return ())+  name exec (return ()) --- | Make 'Logger' that consumes all queued messages once per second.-mkBulkLogger :: T.Text -> ([LogMessage] -> IO ()) -> IO Logger+-- | Start an asynchronous logger thread that consumes all queued+-- messages once per second. To make sure that the messages get+-- written out in the presence of exceptions, use high-level wrappers+-- like 'withElasticSearchLogger' or 'withBulkStdOutLogger' instead of+-- this function directly.+--+-- Note: some messages can be lost when the main thread shuts down+-- without making sure that all logger threads have written out all+-- messages, because in that case child threads are not given a chance+-- to clean up by the RTS. This is apparently a feature:+-- <https://mail.haskell.org/pipermail/haskell-cafe/2014-February/112754.html>+--+-- To work around this issue, make sure that the main thread doesn't+-- exit until all its children have terminated. The 'async' package+-- makes this easy.+--+-- Problematic example:+--+-- @+-- import Control.Concurrent.Async+--+-- main :: IO ()+-- main = do+--    logger <- elasticSearchLogger+--    a <- async (withElasticSearchLogger $ \logger ->+--                runLogT "main" logger $ logTrace_ "foo")+--    -- Main thread exits without waiting for the child+--    -- to finish and without giving the child a chance+--    -- to do proper cleanup.+-- @+--+-- Fixed example:+--+-- @+-- import Control.Concurrent.Async+--+-- main :: IO ()+-- main = do+--    logger <- elasticSearchLogger+--    a <- async (withElasticSearchLogger $ \logger ->+--                runLogT "main" logger $ logTrace_ "foo")+--    wait a+--    -- Main thread waits for the child to finish, giving+--    -- it a chance to shut down properly. This works even+--    -- in the presence of exceptions in the child thread.+-- @+mkBulkLogger :: T.Text -> ([LogMessage] -> IO ()) -> IO () -> IO Logger mkBulkLogger = mkLoggerImpl-  newSQueueIO isEmptySQueue readSQueue writeSQueue $ threadDelay 1000000+  newSQueueIO isEmptySQueue readSQueue writeSQueue (threadDelay 1000000)  ---------------------------------------- --- | Simple STM based queue.+-- | A simple STM based queue. newtype SQueue a = SQueue (TVar [a])  -- | Create an instance of 'SQueue'.@@ -64,29 +111,54 @@              -> IO ()              -> T.Text              -> (msgs -> IO ())+             -> IO ()              -> IO Logger-mkLoggerImpl newQueue isQueueEmpty readQueue writeQueue afterExecDo name exec = do-  (queue, inProgress) <- (,) <$> newQueue <*> newTVarIO False-  finalizer <- newIORef ()-  mask $ \release -> do-    tid <- forkIO . (`finally` printLoggerTerminated) . release . forever $ do+mkLoggerImpl newQueue isQueueEmpty readQueue writeQueue afterExecDo+  name exec sync = do+  queue      <- newQueue+  inProgress <- newTVarIO False+  isRunning  <- newTVarIO True+  tid <- forkFinally (forever $ loop queue inProgress)+                     (\_ -> cleanup queue inProgress)+  return Logger {+    loggerWriteMessage = \msg -> atomically $ do+        checkIsRunning isRunning+        writeQueue queue msg,+    loggerWaitForWrite = do+        atomically $ waitForWrite queue inProgress+        sync,+    loggerShutdown     = do+        killThread tid+        atomically $ writeTVar isRunning False+    }+  where+    checkIsRunning isRunning' = do+      isRunning <- readTVar isRunning'+      when (not isRunning) $+        throwSTM (AssertionFailed $ "Log.Logger.mkLoggerImpl: "+                   ++ "attempt to write to a shut down logger")++    loop queue inProgress = do+      step queue inProgress+      afterExecDo++    step queue inProgress = do       msgs <- atomically $ do         writeTVar inProgress True         readQueue queue       exec msgs       atomically $ writeTVar inProgress False-      afterExecDo-    let waitForWrite = do-          isEmpty <- isQueueEmpty queue-          isInProgress <- readTVar inProgress-          when (not isEmpty || isInProgress) retry-    void . mkWeakIORef finalizer $ do-      atomically waitForWrite-      killThread tid-    return Logger {-      loggerWriteMessage = atomically . writeQueue queue-    , loggerWaitForWrite = waitForWrite-    , loggerFinalizers = [finalizer]-    }-  where++    cleanup queue inProgress = do+      step queue inProgress+      sync+      -- Don't call afterExecDo, since it's either a no-op or a+      -- threadDelay.+      printLoggerTerminated++    waitForWrite queue inProgress = do+      isEmpty <- isQueueEmpty queue+      isInProgress <- readTVar inProgress+      when (not isEmpty || isInProgress) retry+     printLoggerTerminated = T.putStrLn $ name <> ": logger thread terminated"
src/Log/Monad.hs view
@@ -1,3 +1,4 @@+-- | The 'LogT' monad transformer for adding logging capabilities to any monad. {-# LANGUAGE CPP #-} module Log.Monad (     Logger@@ -25,28 +26,41 @@ import Log.Data import Log.Logger --- | 'LogT' environment.+-- | The state that every 'LogT' carries around. data LoggerEnv = LoggerEnv {-  leLogger    :: !Logger-, leComponent :: !Text-, leDomain    :: ![Text]-, leData      :: ![Pair]+  leLogger    :: !Logger -- ^ The 'Logger' to use.+, leComponent :: !Text   -- ^ Current application component.+, leDomain    :: ![Text] -- ^ Current application domain.+, leData      :: ![Pair] -- ^ Additional data to be merged with the+                         -- log message\'s data. }  type InnerLogT = ReaderT LoggerEnv  -- | Monad transformer that adds logging capabilities to the underlying monad. newtype LogT m a = LogT { unLogT :: InnerLogT m a }-  deriving (Alternative, Applicative, Functor, Monad, MonadBase b, MonadCatch, MonadIO, MonadMask, MonadPlus, MonadThrow, MonadTrans)+  deriving (Alternative, Applicative, Functor, Monad, MonadBase b, MonadCatch+           ,MonadIO, MonadMask, MonadPlus, MonadThrow, MonadTrans) -runLogT :: Text -> Logger -> LogT m a -> m a+-- | Run a 'LogT' computation.+--+-- Note that in the case of asynchronous/bulk loggers 'runLogT'+-- doesn't guarantee that all messages are actually written to the log+-- once it finishes. Use 'withPGLogger' or 'withElasticSearchLogger'+-- for that.+runLogT :: Text     -- ^ Application component name to use.+        -> Logger   -- ^ The logging back-end to use.+        -> LogT m a -- ^ The 'LogT' computation to run.+        -> m a runLogT component logger m = runReaderT (unLogT m) LoggerEnv {   leLogger = logger , leComponent = component , leDomain = [] , leData = []-}+} -- We can't do synchronisation here, since 'runLogT' can be invoked+  -- quite often from the application (e.g. on every request). +-- | Transform the computation inside a 'LogT'. mapLogT :: (m a -> n b) -> LogT m a -> LogT n b mapLogT f = LogT . mapReaderT f . unLogT @@ -94,6 +108,8 @@               | otherwise -> object $ ("_data", data_) : leData           } -  localData data_ = LogT . local (\e -> e { leData = data_ ++ leData e }) . unLogT+  localData data_ =+    LogT . local (\e -> e { leData = data_ ++ leData e }) . unLogT -  localDomain domain = LogT . local (\e -> e { leDomain = leDomain e ++ [domain] }) . unLogT+  localDomain domain =+    LogT . local (\e -> e { leDomain = leDomain e ++ [domain] }) . unLogT
+ test/IntegrationTest.hs view
@@ -0,0 +1,54 @@+module Main where++import Log+import Log.Backend.StandardOutput+import Log.Backend.StandardOutput.Bulk+import Log.Backend.ElasticSearch+import Test.ElasticSearch++import Data.List+import System.Environment+import System.Process+import System.Random+import Test.Tasty+import Test.Tasty.HUnit++main :: IO ()+main = do+  args <- getArgs+  case args of+    ["test-simple-stdout"] -> do+      withSimpleStdOutLogger $ \logger ->+        runLogT "log-test-integration" logger $ logTrace_ "kaboozle"+    ["test-bulk-stdout"] -> do+      withBulkStdOutLogger $ \logger ->+        runLogT "log-test-integration" logger $ logTrace_ "kaboozle"+    ["test-elasticsearch"] -> do+      let config = defaultElasticSearchConfig+      withElasticSearchLogger config randomIO $ \logger ->+        runLogT "log-test-integration" logger $ logTrace_ "kaboozle"+    _ -> runTests++runTests :: IO ()+runTests = do+  path <- getExecutablePath+  let config = defaultElasticSearchConfig+  testConfig <- defaultElasticSearchTestConfig config+  defaultMain $ testGroup "Integration Tests" [+    testCase "Log messages are not lost (simple stdout back-end)" $ do+        out <- readProcess path ["test-simple-stdout"] ""+        assertBool "Output doesn't contain 'kaboozle'"+          $ "kaboozle" `isInfixOf` out,++    testCase "Log messages are not lost (bulk stdout back-end)" $ do+        out <- readProcess path ["test-bulk-stdout"] ""+        assertBool "Output doesn't contain 'kaboozle'"+          $ "kaboozle" `isInfixOf` out,++    testCase "Log messages are not lost (Elasticsearch back-end)" $ do+        callProcess path ["test-elasticsearch"]+        refreshTestIndex testConfig+        hits <- getNumHits testConfig "kaboozle"+        assertBool "Elasticsearch returned zero hits for 'kaboozle'"+          $ (hits > 0)+    ]
+ test/Test.hs view
@@ -0,0 +1,41 @@+module Main where++import Log+import Log.Backend.ElasticSearch+import Test.ElasticSearch++import System.Random+import Test.Tasty+import Test.Tasty.HUnit++tests :: ElasticSearchTestConfig -> Logger -> TestTree+tests config logger = testGroup "Unit Tests" [+  testCase "After logging 'foo', 'foo' is there" $ do+      runLogT "log-test" logger $ do+        logTrace_ "foo"+      waitForLogger logger+      hits <- getNumHits config "foo"+      assertBool "expected at least one hit for 'foo'" (hits > 0),+  testCase "After logging 'foo' thrice, searching 'foo' gives >=3 hits" $ do+      runLogT "log-test" logger $ do+        logTrace_ "foo"+        logTrace_ "foo"+        logTrace_ "foo"+      waitForLogger logger+      hits <- getNumHits config "foo"+      assertBool "expected at least 3 hits for 'foo'" (hits >= 3),+  testCase "After logging 'foo' and 'bar', searching 'baz' gives 0 hits" $ do+      runLogT "log-test" logger $ do+        logTrace_ "foo"+        logTrace_ "bar"+      waitForLogger logger+      hits <- getNumHits config "baz"+      assertBool "expected zero hits for 'baz'"  (hits == 0)+  ]++main :: IO ()+main = do+  let config = defaultElasticSearchConfig+  testConfig <- defaultElasticSearchTestConfig config+  withElasticSearchLogger config randomIO $ \logger ->+    defaultMain $ tests testConfig logger
+ test/Test/ElasticSearch.hs view
@@ -0,0 +1,58 @@+module Test.ElasticSearch+  (ElasticSearchTestConfig(..)+  ,defaultElasticSearchConfig+  ,defaultElasticSearchTestConfig+  ,getNumHits+  ,refreshTestIndex)+where++import Log.Backend.ElasticSearch++import Control.Monad+import Data.Aeson+import Data.Text (Text)+import Data.Time+import Database.Bloodhound+import Network.HTTP.Client+import Test.Tasty.HUnit++import qualified Data.Text as T++data ElasticSearchTestConfig = ElasticSearchTestConfig {+  testServer :: Server,+  testIndex  :: IndexName+  }++defaultElasticSearchTestConfig :: ElasticSearchConfig+                               -> IO ElasticSearchTestConfig+defaultElasticSearchTestConfig ElasticSearchConfig{..} = do+  now <- getCurrentTime+  let testServer = Server esServer+      testIndex  = IndexName $ T.concat+                   [ esIndex+                   , "-"+                   , T.pack $ formatTime defaultTimeLocale "%F" now+                   ]+  return ElasticSearchTestConfig{..}+++getNumHits :: ElasticSearchTestConfig -> Text -> IO Int+getNumHits ElasticSearchTestConfig{..} query = do+  let tquery = TermQuery (Term "message" query) Nothing+      search = mkSearch (Just tquery) Nothing+  reply <- withBH' $ searchByIndex testIndex search+  let result = eitherDecode (responseBody reply)+        :: Either String (SearchResult Value)+  case result of+    Left err -> assertString err >> return (-1)+    Right res -> return . hitsTotal . searchHits $ res++  where+    withBH' = withBH defaultManagerSettings testServer++refreshTestIndex :: ElasticSearchTestConfig -> IO ()+refreshTestIndex ElasticSearchTestConfig{..} =+  void . withBH' $ refreshIndex testIndex++  where+    withBH' = withBH defaultManagerSettings testServer