diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,44 @@
+# log-base-0.12.1.0 (2025-06-26)
+* Add utility function to log unhandled exceptions.
+
+# log-base-0.12.0.1 (2023-03-14)
+* Add support for GHC 9.6.
+
+# log-base-0.12.0.0 (2022-09-21)
+* Deprecate `withSimpleStdOutLogger` as it's broken in multithreaded environments.
+* Generalize logger related functions to `MonadUnliftIO`.
+* Remove redundant `INLINE` pragmas.
+
+# log-base-0.11.1.0 (2022-04-04)
+* Add support for aeson 2.0.1.0.
+* Add support for GHC 9.2.
+* Drop support for GHC < 8.8.
+
+# log-base-0.11.0.0 (2021-10-11)
+* Add support for defining maximum log level.
+
+# log-base-0.10.0.1 (2021-07-29)
+* Fix compilation issues caused by ambiguos occurence of `controlT`.
+
+# log -base-0.10.0.0 (2021-06-09)
+* Drop `MonadTime` constraint and use system time by default.
+
+# log-base-0.9.1.1 (2021-05-28)
+* Support GHC 9.0.
+
+# log-base-0.9.1.0 (2021-03-01)
+* Add a `LogList` logger
+
+# log-base-0.9.0.0 (2020-09-07)
+* Always make data attached to a log message a json object
+* Add unliftio-core-0.2 compatiblity
+* Tidy up flushing stdout in stdout loggers
+* Use `simpleStdoutLogger` in `withSimpleStdOutLogger` instead of `stdoutLogger`
+* Remove deprecated functions
+* Add JSON loggers
+* Make `mkLogger` use bounded queue internally (similar to `mkBulkLogger`)
+* Get rid of a space leak in bounded queue used in `mkBulkLogger`
+
 # log-base-0.8.0.1 (2020-05-08)
 * Update version bounds.
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,5 +1,40 @@
-# log-base [![Hackage version](https://img.shields.io/hackage/v/log-base.svg?label=Hackage)](https://hackage.haskell.org/package/log-base) [![Build Status](https://secure.travis-ci.org/scrive/log.svg?branch=master)](http://travis-ci.org/scrive/log)
+# log
 
-Base package for the `log` library suite. Includes only the standard output
-back end. Use this package in conjunction with `log-elasticsearch` or
-`log-postgres`, depending on which back end you need.
+[![Hackage version](https://img.shields.io/hackage/v/log-base.svg?label=Hackage)](https://hackage.haskell.org/package/log-base)
+[![Build Status](https://github.com/scrive/log/workflows/Haskell-CI/badge.svg?branch=master)](https://github.com/scrive/log/actions?query=branch%3Amaster)
+
+A set of libraries that provide a way to record structured log messages with
+multiple backends.
+
+Supported backends:
+
+* Standard output via
+  [`log-base`](https://hackage.haskell.org/package/log-base).
+* Elasticsearch via
+  [`log-elasticsearch`](https://hackage.haskell.org/package/log-elasticsearch).
+* PostgreSQL via
+  [`log-postgres`](https://hackage.haskell.org/package/log-postgres).
+
+## Example
+
+A sample usage for logging to both standard output and Elasticsearch:
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+module Main where
+
+import Log
+import Log.Backend.ElasticSearch
+import Log.Backend.StandardOutput
+
+main :: IO ()
+main = do
+  let config = defaultElasticSearchConfig
+        { esServer = "http://localhost:9200"
+        , esIndex  = "logs"
+        }
+  withStdOutLogger $ \stdoutLogger -> do
+    withElasticSearchLogger config $ \esLogger -> do
+      runLogT "main" (stdoutLogger <> esLogger) defaultLogLevel $ do
+        logInfo_ "Hi there"
+```
diff --git a/log-base.cabal b/log-base.cabal
--- a/log-base.cabal
+++ b/log-base.cabal
@@ -1,5 +1,6 @@
+cabal-version:       3.0
 name:                log-base
-version:             0.8.0.1
+version:             0.12.1.0
 synopsis:            Structured logging solution (base package)
 
 description:         A library that provides a way to record structured log
@@ -9,7 +10,7 @@
 
 
 homepage:            https://github.com/scrive/log
-license:             BSD3
+license:             BSD-3-Clause
 license-file:        LICENSE
 author:              Scrive AB
 maintainer:          Andrzej Rybczak <andrzej@rybczak.net>,
@@ -19,44 +20,44 @@
 copyright:           Scrive AB
 category:            System
 build-type:          Simple
-cabal-version:       >=1.10
 extra-source-files:  CHANGELOG.md, README.md
-tested-with:         GHC ==8.0.2 || ==8.2.2 || ==8.4.4 || ==8.6.5 || ==8.8.3 || ==8.10.1
+tested-with:         GHC == { 8.10.7, 9.0.2, 9.2.8, 9.4.8, 9.6.7, 9.8.4, 9.10.2, 9.12.2 }
 
-Source-repository head
-  Type:     git
-  Location: https://github.com/scrive/log.git
+source-repository head
+  type:     git
+  location: https://github.com/scrive/log.git
 
 library
   exposed-modules:     Log,
+                       Log.Backend.LogList,
                        Log.Backend.StandardOutput,
                        Log.Backend.StandardOutput.Bulk,
                        Log.Backend.Text,
                        Log.Class,
                        Log.Data,
+                       Log.Internal.Aeson.Compat,
                        Log.Internal.Logger,
                        Log.Logger,
                        Log.Monad
-  build-depends:       base >= 4.9 && <5,
-                       aeson >=0.11.0.0,
+  build-depends:       base >= 4.13 && <5,
+                       aeson >= 1.0,
                        aeson-pretty >=0.8.2,
                        bytestring,
                        deepseq,
                        exceptions >=0.6,
-                       mmorph >=1.0.9 && <1.2,
-                       monad-control >=0.3,
-                       monad-time >= 0.2,
+                       mmorph >=1.0.9 && <1.3,
+                       monad-control >=1.0.3,
                        mtl,
                        semigroups,
                        stm >=2.4,
                        text,
                        time >= 1.5,
                        transformers-base,
-                       unliftio-core >= 0.1.2.0 && < 0.2,
+                       unliftio-core >= 0.1.2.0 && < 0.3,
                        unordered-containers
   hs-source-dirs:      src
 
-  ghc-options:         -O2 -Wall -funbox-strict-fields
+  ghc-options:         -Wall
 
   default-language:   Haskell2010
   default-extensions: BangPatterns
@@ -65,7 +66,6 @@
                     , GeneralizedNewtypeDeriving
                     , LambdaCase
                     , MultiParamTypeClasses
-                    , NoImplicitPrelude
                     , OverloadedStrings
                     , RankNTypes
                     , RecordWildCards
diff --git a/src/Log.hs b/src/Log.hs
--- a/src/Log.hs
+++ b/src/Log.hs
@@ -18,7 +18,7 @@
 --         esMapping = "log"
 --         }
 --   'Log.Backend.ElasticSearch.withElasticSearchLogger' config randomIO $ \\logger -> do
---     'runLogT' "main" logger $ do
+--     'runLogT' "main" logger defaultLogLevel $ do
 --       'logTrace_' "foo"
 -- @
 module Log (
diff --git a/src/Log/Backend/LogList.hs b/src/Log/Backend/LogList.hs
new file mode 100644
--- /dev/null
+++ b/src/Log/Backend/LogList.hs
@@ -0,0 +1,44 @@
+-- | LogList logging backed.
+module Log.Backend.LogList
+  ( LogList
+  , newLogList
+  , getLogList
+  , putLogList
+  , clearLogList
+  , withLogListLogger
+  ) where
+
+import Control.Concurrent.MVar
+import Control.Monad.IO.Unlift
+
+import Log.Data
+import Log.Internal.Logger
+
+newtype LogList = LogList (MVar [LogMessage])
+  deriving Eq
+
+-- | Create a new, empty list.
+newLogList :: MonadIO m => m LogList
+newLogList = LogList <$> liftIO (newMVar [])
+
+-- | Retrieve messages stored in the list.
+getLogList :: MonadIO m => LogList -> m [LogMessage]
+getLogList (LogList ll) = reverse <$> liftIO (readMVar ll)
+
+-- | Put a message into the list.
+putLogList :: MonadIO m => LogList -> LogMessage -> m ()
+putLogList (LogList ll) msg = liftIO . modifyMVar_ ll $ \msgs -> return $! msg : msgs
+
+-- | Clear the list.
+clearLogList :: MonadIO m => LogList -> m ()
+clearLogList (LogList ll) = liftIO . modifyMVar_ ll . const $ return []
+
+-- | Creates a logger that stores messages in the given 'LogList'.
+withLogListLogger :: MonadUnliftIO m => LogList -> (Logger -> m r) -> m r
+withLogListLogger ll act = withRunInIO $ \unlift -> withLogger logger (unlift . act)
+  where
+    logger = Logger
+      { loggerWriteMessage = putLogList ll
+      , loggerWaitForWrite = return ()
+      , loggerShutdown     = return ()
+      }
diff --git a/src/Log/Backend/StandardOutput.hs b/src/Log/Backend/StandardOutput.hs
--- a/src/Log/Backend/StandardOutput.hs
+++ b/src/Log/Backend/StandardOutput.hs
@@ -1,41 +1,38 @@
 -- | Stdout logging back-end.
-module Log.Backend.StandardOutput (
-    simpleStdoutLogger
-  , stdoutLogger
-  , withSimpleStdOutLogger
+module Log.Backend.StandardOutput
+  ( withSimpleStdOutLogger
+  , withStdOutLogger
+  , withJsonStdOutLogger
   ) where
 
-import Prelude
-import qualified Data.Text.IO as T
+import Control.Monad.IO.Unlift
+import Data.Aeson
 import System.IO
+import qualified Data.Text.IO as T
+import qualified Data.ByteString.Lazy.Char8 as BSL
 
 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
-  withLogger logger act
-
-{-# DEPRECATED simpleStdoutLogger "Use 'withSimpleStdOutLogger'" #-}
-
--- | Simple, synchronous logger that prints messages to standard
--- output. Flushes 'stdout' on each call to 'loggerWriteMessage'. Use
--- 'Log.Backend.StandardOutput.Bulk.withBulkStdOutLogger' if you want
--- buffering.
-simpleStdoutLogger :: Logger
-simpleStdoutLogger = Logger {
-    loggerWriteMessage = \msg -> (T.putStrLn . showLogMessage Nothing $ msg)
-                                 >> hFlush stdout
-  , loggerWaitForWrite = hFlush stdout
-  , loggerShutdown     = return ()
-  }
+withSimpleStdOutLogger :: MonadUnliftIO m => (Logger -> m r) -> m r
+withSimpleStdOutLogger = withStdOutLogger
+{-# DEPRECATED withSimpleStdOutLogger "Use withStdOutLogger instead" #-}
 
-{-# DEPRECATED stdoutLogger "Use 'withSimpleStdOutLogger'" #-}
+-- | Create a logger that prints messages to standard output for the duration of
+-- the given action.
+withStdOutLogger :: MonadUnliftIO m => (Logger -> m r) -> m r
+withStdOutLogger act = withRunInIO $ \unlift -> do
+  logger <- mkLogger "stdout" $ \msg -> do
+    T.putStrLn $ showLogMessage Nothing msg
+    hFlush stdout
+  withLogger logger (unlift . act)
 
--- | Create a logger that prints messages to standard output.
-stdoutLogger :: IO Logger
-stdoutLogger = mkLogger "stdout" $ T.putStrLn . showLogMessage Nothing
+-- | Create a logger that prints messages in the JSON format to standard output
+-- for the duration of the given action.
+withJsonStdOutLogger :: MonadUnliftIO m => (Logger -> m r) -> m r
+withJsonStdOutLogger act = withRunInIO $ \unlift -> do
+  logger <- mkLogger "stdout-json" $ \msg -> do
+    BSL.putStrLn $ encode msg
+    hFlush stdout
+  withLogger logger (unlift . act)
diff --git a/src/Log/Backend/StandardOutput/Bulk.hs b/src/Log/Backend/StandardOutput/Bulk.hs
--- a/src/Log/Backend/StandardOutput/Bulk.hs
+++ b/src/Log/Backend/StandardOutput/Bulk.hs
@@ -1,34 +1,39 @@
--- | Bulk stdout logging back-end, useful mainly for testing.
-module Log.Backend.StandardOutput.Bulk (
-    withBulkStdOutLogger,
-    bulkStdoutLogger
+-- | Bulk stdout logging back-end.
+module Log.Backend.StandardOutput.Bulk
+  ( withBulkStdOutLogger
+  , withBulkJsonStdOutLogger
   ) where
 
-import Prelude
-import qualified Data.Text.IO as T
+import Control.Monad.IO.Unlift
+import Data.Aeson
 import System.IO (hFlush, stdout)
+import qualified Data.Text.IO as T
+import qualified Data.ByteString.Lazy.Char8 as BSL
 
 import Log.Data
 import Log.Logger
 import Log.Internal.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. Flushes 'stdout' on each bulk write.
-withBulkStdOutLogger :: (Logger -> IO r) -> IO r
-withBulkStdOutLogger act = do
-  logger <- bulkStdoutLogger
-  withLogger logger act
-
-{-# DEPRECATED bulkStdoutLogger "Use 'withBulkStdOutLogger' instead!" #-}
+-- | Create an asynchronouis logger thread that prints messages to standard
+-- output once per second for the duration of the given action. Flushes 'stdout'
+-- on each bulk write.
+withBulkStdOutLogger :: MonadUnliftIO m => (Logger -> m r) -> m r
+withBulkStdOutLogger act = withRunInIO $ \unlift -> do
+  logger <- mkBulkLogger "stdout-bulk"
+    (\msgs -> do
+        mapM_ (T.putStrLn . showLogMessage Nothing) msgs
+        hFlush stdout
+    ) (return ())
+  withLogger logger (unlift . act)
 
--- | 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"
-                   (\msgs -> mapM_ (T.putStrLn . showLogMessage Nothing) msgs
-                             >> hFlush stdout)
-                   (hFlush stdout)
+-- | Create a bulk logger that prints messages in the JSON format to standard
+-- output once per second for the duration of the given action. Flushes 'stdout'
+-- on each bulk write.
+withBulkJsonStdOutLogger :: MonadUnliftIO m => (Logger -> m r) -> m r
+withBulkJsonStdOutLogger act = withRunInIO $ \unlift -> do
+  logger <- mkBulkLogger "stdout-bulk-json"
+    (\msgs -> do
+        mapM_ (BSL.putStrLn . encode) msgs
+        hFlush stdout
+    ) (return ())
+  withLogger logger (unlift . act)
diff --git a/src/Log/Backend/Text.hs b/src/Log/Backend/Text.hs
--- a/src/Log/Backend/Text.hs
+++ b/src/Log/Backend/Text.hs
@@ -2,10 +2,8 @@
 -- testing.
 module Log.Backend.Text ( withSimpleTextLogger ) where
 
-import Control.Applicative
+import Control.Monad.IO.Unlift
 import Data.IORef
-import Data.Monoid
-import Prelude
 import qualified Data.Text as T
 import qualified Data.Text.Lazy as L
 import qualified Data.Text.Lazy.Builder as B
@@ -16,8 +14,8 @@
 -- | Create an in-memory logger for the duration of the given action,
 -- returning both the result of the action and the logger's output as
 -- a 'Text' value afterwards.
-withSimpleTextLogger :: (Logger -> IO r) -> IO (T.Text, r)
-withSimpleTextLogger act = do
+withSimpleTextLogger :: MonadUnliftIO m => (Logger -> m r) -> m (T.Text, r)
+withSimpleTextLogger act = withRunInIO $ \unlift -> do
   builderRef <- newIORef mempty
   let logger = Logger
         { loggerWriteMessage = \msg -> do
@@ -26,6 +24,6 @@
         , loggerWaitForWrite = return ()
         , loggerShutdown     = return ()
         }
-  r <- act logger
+  r <- unlift $ act logger
   txt <- L.toStrict . B.toLazyText <$> readIORef builderRef
   return (txt, r)
diff --git a/src/Log/Class.hs b/src/Log/Class.hs
--- a/src/Log/Class.hs
+++ b/src/Log/Class.hs
@@ -1,9 +1,6 @@
 -- | The 'MonadLog' type class of monads with logging capabilities.
-{-# OPTIONS_GHC -fno-warn-deprecated-flags #-}
-{-# LANGUAGE OverlappingInstances #-}
 module Log.Class (
     UTCTime
-  , MonadTime(..)
   , MonadLog(..)
   , logAttention
   , logInfo
@@ -13,13 +10,11 @@
   , logTrace_
   ) where
 
-import Control.Monad.Time
 import Control.Monad.Trans
 import Control.Monad.Trans.Control
 import Data.Aeson
 import Data.Aeson.Types
 import Data.Time
-import Prelude
 import qualified Data.Text as T
 
 import Log.Data
@@ -29,52 +24,51 @@
 -- '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
+class Monad m => MonadLog m where
   -- | 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 ()
+  logMessage
+    :: 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
+  -- | Override the current maximum log level.
+  localMaxLogLevel :: LogLevel -> m a -> m a
   -- | Get current 'LoggerEnv' object. Useful for construction of logging
   -- functions that work in a different monad, see 'getLoggerIO' as an example.
   getLoggerEnv :: m LoggerEnv
 
 -- | Generic, overlapping instance.
-instance (
+instance {-# OVERLAPPABLE #-} (
     MonadLog m
   , Monad (t m)
   , MonadTransControl t
   ) => MonadLog (t m) where
-    logMessage time level message = lift . logMessage time level message
+    logMessage level message = lift . logMessage level message
     localData data_ m = controlT $ \run -> localData data_ (run m)
     localDomain domain m = controlT $ \run -> localDomain domain (run m)
+    localMaxLogLevel level m = controlT $ \run -> localMaxLogLevel level (run m)
     getLoggerEnv = lift getLoggerEnv
 
-controlT :: (MonadTransControl t, Monad (t m), Monad m)
-         => (Run t -> m (StT t a)) -> t m a
-controlT f = liftWith f >>= restoreT . return
-
 ----------------------------------------
 
 -- | Log a message and its associated data using current time as the
 -- event time and the 'LogAttention' log level.
 logAttention :: (MonadLog m, ToJSON a) => T.Text -> a -> m ()
-logAttention msg = logNow LogAttention msg . toJSON
+logAttention msg = logMessage LogAttention msg . toJSON
 
 -- | Log a message and its associated data using current time as the
 -- event time and the 'LogInfo' log level.
 logInfo :: (MonadLog m, ToJSON a) => T.Text -> a -> m ()
-logInfo msg = logNow LogInfo msg . toJSON
+logInfo msg = logMessage LogInfo msg . toJSON
 
 -- | Log a message and its associated data using current time as the
 -- event time and the 'LogTrace' log level.
 logTrace :: (MonadLog m, ToJSON a) => T.Text -> a -> m ()
-logTrace msg = logNow LogTrace msg . toJSON
+logTrace msg = logMessage LogTrace msg . toJSON
 
 -- | Like 'logAttention', but without any additional associated data.
 logAttention_ :: MonadLog m => T.Text -> m ()
@@ -87,12 +81,3 @@
 -- | 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
-  logMessage time level message data_
diff --git a/src/Log/Data.hs b/src/Log/Data.hs
--- a/src/Log/Data.hs
+++ b/src/Log/Data.hs
@@ -3,8 +3,10 @@
     LogLevel(..)
   , showLogLevel
   , readLogLevel
+  , readLogLevelEither
   , LogMessage(..)
   , showLogMessage
+  , defaultLogLevel
   ) where
 
 import Control.DeepSeq
@@ -14,19 +16,19 @@
 import Data.Aeson.Types
 import Data.ByteString.Lazy (toStrict)
 import Data.Time
-import Prelude
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as T
 import qualified Data.Monoid as Monoid
 
 -- | Available log levels.
+-- Note that ordering in this definintion determines what the maximum log level is.
+-- See 'Log.Monad.leMaxLogLevel'.
 data LogLevel = LogAttention | LogInfo | LogTrace
   deriving (Bounded, Eq, Ord, Show)
 
 -- | This function is partial.
 readLogLevel :: T.Text -> LogLevel
 readLogLevel = either error id . readLogLevelEither
-{-# INLINE readLogLevel #-}
 
 readLogLevelEither :: T.Text -> Either String LogLevel
 readLogLevelEither "attention" = Right LogAttention
@@ -39,6 +41,10 @@
 showLogLevel LogAttention = "attention"
 showLogLevel LogInfo      = "info"
 showLogLevel LogTrace     = "trace"
+
+-- | The default log level. Returns `LogInfo`.
+defaultLogLevel :: LogLevel
+defaultLogLevel = LogInfo
 
 instance ToJSON LogLevel where
   toJSON = toJSON . showLogLevel
diff --git a/src/Log/Internal/Aeson/Compat.hs b/src/Log/Internal/Aeson/Compat.hs
new file mode 100644
--- /dev/null
+++ b/src/Log/Internal/Aeson/Compat.hs
@@ -0,0 +1,35 @@
+{-# LANGUAGE CPP #-}
+module Log.Internal.Aeson.Compat
+  ( module Map
+#if !MIN_VERSION_aeson(2,0,1)
+  , KeyMap
+#endif
+  , fromText
+  , doName
+  ) where
+
+import Data.Text
+
+#if MIN_VERSION_aeson(2,0,1)
+import Data.Aeson.KeyMap as Map 
+import qualified Data.Aeson.Key as K
+
+fromText :: Text -> K.Key
+fromText = K.fromText
+
+doName :: Monad m => (Text -> m Text) -> K.Key -> m K.Key
+doName f = fmap K.fromText . f . K.toText
+
+#else
+
+import Data.HashMap.Strict as Map
+
+type KeyMap a = Map.HashMap Text a
+
+fromText :: Text -> Text
+fromText = id
+
+doName :: (Text -> m Text) -> Text -> m Text
+doName = id
+
+#endif
diff --git a/src/Log/Internal/Logger.hs b/src/Log/Internal/Logger.hs
--- a/src/Log/Internal/Logger.hs
+++ b/src/Log/Internal/Logger.hs
@@ -8,9 +8,7 @@
   , withLogger
   ) where
 
-import Data.Semigroup
 import Control.Exception
-import Prelude
 
 import Log.Data
 
@@ -50,6 +48,10 @@
 withLogger logger act = act logger `finally` cleanup
   where
     cleanup = waitForLogger logger >> shutdownLogger logger
+-- Prevent GHC from inlining this function so its callers are small and
+-- considered for inlining instead (as they will be generalized to MonadIO or
+-- MonadUnliftIO).
+{-# NOINLINE withLogger #-}
 
 instance Semigroup Logger where
   l1 <> l2 = Logger {
diff --git a/src/Log/Logger.hs b/src/Log/Logger.hs
--- a/src/Log/Logger.hs
+++ b/src/Log/Logger.hs
@@ -3,6 +3,7 @@
   ( LoggerEnv(..)
   , Logger
   , mkLogger
+  , mkLogger'
   , mkBulkLogger
   , mkBulkLogger'
   , execLogger
@@ -10,13 +11,10 @@
   , shutdownLogger
   ) where
 
-import Control.Applicative
 import Control.Concurrent
 import Control.Concurrent.STM
 import Control.Exception
 import Control.Monad
-import Data.Semigroup
-import Prelude
 import qualified Data.Aeson.Types as A
 import qualified Data.Text as T
 import qualified Data.Text.IO as T
@@ -31,23 +29,37 @@
   , leDomain    :: ![T.Text] -- ^ Current application domain.
   , leData      :: ![A.Pair] -- ^ Additional data to be merged with the log
                              -- message\'s data.
+  , leMaxLogLevel :: LogLevel -- ^ The maximum log level allowed to be logged.
   }
 
 -- | Start a logger thread that consumes one queued message at a time.
+--
+-- /Note:/ a bounded queue of size 1000000 is used internally to avoid
+-- unrestricted memory consumption.
 mkLogger :: T.Text -> (LogMessage -> IO ()) -> IO Logger
-mkLogger name exec = mkLoggerImpl
-  newTQueueIO isEmptyTQueue readTQueue writeTQueue (return ())
-  name exec (return ())
+mkLogger = mkLogger' defaultQueueCapacity
 
--- | Start an asynchronous logger thread that consumes all queued
--- messages once per second. Uses a bounded queue internally to avoid
--- space leaks. To make sure that the messages get written out in the
--- presence of exceptions, use high-level wrappers like 'withLogger',
+-- | Like 'mkBulkLogger', but with configurable queue size.
+--
+-- @since 0.9.0.0
+mkLogger' :: Int -> T.Text -> (LogMessage -> IO ()) -> IO Logger
+mkLogger' cap name exec = mkLoggerImpl
+  (newTBQueueIO $ fromIntegral cap) isEmptyTBQueue readTBQueue writeTBQueue
+  (return ()) name exec (return ())
+
+-- | Start an asynchronous logger thread that consumes all queued messages once
+-- per second.
+--
+-- /Note:/ a bounded queue of size 1000000 is used internally to avoid
+-- unrestricted memory consumption.
+--
+-- To make sure that the messages get written out in the presence of exceptions,
+-- use high-level wrappers like 'withLogger',
 -- 'Log.Backend.ElasticSearch.withElasticSearchLogger' or
--- 'Log.Backend.StandardOutput.Bulk.withBulkStdOutLogger' instead of
--- this function directly.
+-- 'Log.Backend.StandardOutput.Bulk.withBulkStdOutLogger' instead of this
+-- function directly.
 --
--- Note: some messages can be lost when the main thread shuts down
+-- /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:
@@ -66,7 +78,7 @@
 -- main = do
 --    logger \<- 'Log.Backend.ElasticSearch.elasticSearchLogger'
 --    a \<- 'Control.Concurrent.Async.async' ('Log.Backend.ElasticSearch.withElasticSearchLogger' $ \\logger ->
---                'Log.Monad.runLogT' "main" logger $ 'Log.Class.logTrace_' "foo")
+--                'Log.Monad.runLogT' "main" logger defaultLogLevel $ 'Log.Class.logTrace_' "foo")
 --    -- Main thread exits without waiting for the child
 --    -- to finish and without giving the child a chance
 --    -- to do proper cleanup.
@@ -81,14 +93,14 @@
 -- main = do
 --    logger \<- 'Log.Backend.ElasticSearch.elasticSearchLogger'
 --    a \<- 'Control.Concurrent.Async.async' ('Log.Backend.ElasticSearch.withElasticSearchLogger' $ \\logger ->
---                'Log.Monad.runLogT' "main" logger $ 'Log.Class.logTrace_' "foo")
+--                'Log.Monad.runLogT' "main" logger defaultLogLevel $ 'Log.Class.logTrace_' "foo")
 --    'Control.Concurrent.Async.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 = mkBulkLogger' sbDefaultCapacity 1000000
+mkBulkLogger = mkBulkLogger' defaultQueueCapacity 1000000
 
 -- | Like 'mkBulkLogger', but with configurable queue size and thread delay.
 --
@@ -106,14 +118,15 @@
 
 ----------------------------------------
 
+-- | Default capacity of log queues (TBQueue for regular logger, 'SBQueue' for
+-- bulk loggers). This corresponds to approximately 200 MiB memory residency
+-- when the queue is full.
+defaultQueueCapacity :: Int
+defaultQueueCapacity = 1000000
+
 -- | A simple STM based bounded queue.
 data SBQueue a = SBQueue !(TVar [a]) !(TVar Int) !Int
 
--- | Default capacity of a 'SBQueue'. This corresponds to
--- approximately 200 MiB memory residency when the queue is full.
-sbDefaultCapacity :: Int
-sbDefaultCapacity = 1000000
-
 -- | Create an instance of 'SBQueue' with a given capacity.
 newSBQueueIO :: Int -> IO (SBQueue a)
 newSBQueueIO capacity = SBQueue <$> newTVarIO [] <*> newTVarIO 0 <*> pure capacity
@@ -141,7 +154,8 @@
   numElems <- readTVar count
   if numElems < capacity
     then do modifyTVar queue (a :)
-            modifyTVar count (+1)
+            -- Strict modification of the queue size to avoid space leak
+            modifyTVar' count (+1)
     else return ()
 
 ----------------------------------------
@@ -204,3 +218,7 @@
       when (not isEmpty || isInProgress) retry
 
     printLoggerTerminated = T.putStrLn $ name <> ": logger thread terminated"
+-- Prevent GHC from inlining this function so its callers are small and
+-- considered for inlining instead (as they will be generalized to MonadIO or
+-- MonadUnliftIO).
+{-# NOINLINE mkLoggerImpl #-}
diff --git a/src/Log/Monad.hs b/src/Log/Monad.hs
--- a/src/Log/Monad.hs
+++ b/src/Log/Monad.hs
@@ -6,6 +6,7 @@
   , InnerLogT
   , LogT(..)
   , runLogT
+  , logExceptions
   , mapLogT
   , logMessageIO
   , getLoggerIO
@@ -13,10 +14,10 @@
 
 import Control.Applicative
 import Control.DeepSeq
+import Control.Monad
 import Control.Monad.Base
 import Control.Monad.Catch
 import Control.Monad.Error.Class
-import Control.Monad.Fail (MonadFail)
 import Control.Monad.IO.Unlift
 import Control.Monad.Morph (MFunctor (..))
 import Control.Monad.Reader
@@ -25,20 +26,21 @@
 import Control.Monad.Writer.Class
 import Data.Aeson
 import Data.Text (Text)
-import Prelude
+import Data.Time
+import qualified Control.Monad.Fail as MF
 import qualified Control.Exception as E
-import qualified Data.HashMap.Strict as H
 
 import Log.Class
 import Log.Data
 import Log.Logger
+import qualified Log.Internal.Aeson.Compat as AC
 
 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, MonadFail
+           ,MonadIO, MonadMask, MonadPlus, MonadThrow, MonadTrans, MF.MonadFail
            ,MonadError e, MonadWriter w, MonadState s)
 
 instance MonadReader r m => MonadReader r (LogT m) where
@@ -53,16 +55,38 @@
 -- for that.
 runLogT :: Text     -- ^ Application component name to use.
         -> Logger   -- ^ The logging back-end to use.
+        -> LogLevel -- ^ The maximum log level allowed to be logged.
+                    --   Only messages less or equal than this level with be logged.
         -> LogT m a -- ^ The 'LogT' computation to run.
         -> m a
-runLogT component logger m = runReaderT (unLogT m) LoggerEnv {
+runLogT component logger maxLogLevel m = runReaderT (unLogT m) LoggerEnv {
   leLogger = logger
 , leComponent = component
 , leDomain = []
 , leData = []
+, leMaxLogLevel = maxLogLevel
 } -- We can't do synchronisation here, since 'runLogT' can be invoked
   -- quite often from the application (e.g. on every request).
 
+-- | Ensure uncaught exceptions get logged.
+-- Convenient to compose right after `runLogT` so any exception
+-- will show up.
+logExceptions :: (MonadBaseControl IO m, MonadLog m) => m a -> m a
+logExceptions f =
+  liftedCatch f $ \(SomeException e) -> do
+      logAttention "Uncaught exception" $ object ["exception" .= show e]
+      liftBase $ E.throwIO e
+
+-- Generalized version of catch taken from `lifted-base`.
+liftedCatch :: (MonadBaseControl IO m, Exception e)
+      => m a       -- ^ The computation to run.
+      -> (e -> m a) -- ^ Handler to invoke if an exception is raised.
+      -> m a
+liftedCatch a handler = control $ \runInIO ->
+  E.catch
+    (runInIO a)
+    (runInIO . handler)
+
 -- | Transform the computation inside a 'LogT'.
 mapLogT :: (m a -> n b) -> LogT m a -> LogT n b
 mapLogT f = LogT . mapReaderT f . unLogT
@@ -71,7 +95,8 @@
 -- 'LoggerEnv'. Useful for reimplementation of 'MonadLog' instance.
 logMessageIO :: LoggerEnv -> UTCTime -> LogLevel -> Text -> Value -> IO ()
 logMessageIO LoggerEnv{..} time level message data_ =
-  execLogger leLogger =<< E.evaluate (force lm)
+  when (level <= leMaxLogLevel) $
+    execLogger leLogger =<< E.evaluate (force lm)
   where
     lm = LogMessage
       { lmComponent = leComponent
@@ -80,11 +105,23 @@
       , lmLevel = level
       , lmMessage = message
       , lmData = case data_ of
-          Object obj -> Object . H.union obj $ H.fromList leData
-          _ | null leData -> data_
-            | otherwise -> object $ ("_data", data_) : leData
+          -- If lmData is not an object, we make it so and put previous data as
+          -- the singleton value with key reflecting its type. It's required for
+          -- ElasticSearch as ES needs fields with the same name to be of the
+          -- same type in all log messages.
+          Object obj      -> Object . AC.union obj $ AC.fromList leData
+          _ | null leData -> object [dataTyped data_ .= data_]
+            | otherwise   -> object $ (dataTyped data_, data_) : leData
       }
 
+    dataTyped = \case
+      Object{} -> "__data_object"
+      Array{}  -> "__data_array"
+      String{} -> "__data_string"
+      Number{} -> "__data_number"
+      Bool{}   -> "__data_bool"
+      Null{}   -> "__data_null"
+
 -- | Return an IO action that logs messages using the current 'MonadLog'
 -- context. Useful for interfacing with libraries such as @aws@ or @amazonka@
 -- that accept logging callbacks operating in IO.
@@ -95,47 +132,33 @@
 --
 -- @since 0.7.2
 instance MFunctor LogT where
-    hoist = mapLogT
+    hoist f = mapLogT f
 
 instance MonadTransControl LogT where
-#if MIN_VERSION_monad_control(1,0,0)
   type StT LogT m = StT InnerLogT m
   liftWith = defaultLiftWith LogT unLogT
   restoreT = defaultRestoreT LogT
-#else
-  newtype StT LogT m = StLogT { unStLogT :: StT InnerLogT m }
-  liftWith = defaultLiftWith LogT unLogT StLogT
-  restoreT = defaultRestoreT LogT unStLogT
-#endif
-  {-# INLINE liftWith #-}
-  {-# INLINE restoreT #-}
 
 instance MonadBaseControl b m => MonadBaseControl b (LogT m) where
-#if MIN_VERSION_monad_control(1,0,0)
   type StM (LogT m) a = ComposeSt LogT m a
   liftBaseWith = defaultLiftBaseWith
   restoreM     = defaultRestoreM
-#else
-  newtype StM (LogT m) a = StMLogT { unStMLogT :: ComposeSt LogT m a }
-  liftBaseWith = defaultLiftBaseWith StMLogT
-  restoreM     = defaultRestoreM unStMLogT
-#endif
-  {-# INLINE liftBaseWith #-}
-  {-# INLINE restoreM #-}
 
 instance MonadUnliftIO m => MonadUnliftIO (LogT m) where
-  askUnliftIO = do
-    UnliftIO runInIO <- LogT askUnliftIO
-    return $ UnliftIO $ runInIO . unLogT
+  withRunInIO inner = LogT $ withRunInIO $ \run -> inner (run . unLogT)
 
-instance (MonadBase IO m, MonadTime m) => MonadLog (LogT m) where
-  logMessage time level message data_ = LogT . ReaderT $ \logEnv ->
-    liftBase $ logMessageIO logEnv time level message data_
+instance MonadBase IO m => MonadLog (LogT m) where
+  logMessage level message data_ = LogT . ReaderT $ \logEnv -> liftBase $ do
+    time <- getCurrentTime
+    logMessageIO logEnv time level message data_
 
   localData data_ =
     LogT . local (\e -> e { leData = data_ ++ leData e }) . unLogT
 
   localDomain domain =
     LogT . local (\e -> e { leDomain = leDomain e ++ [domain] }) . unLogT
+
+  localMaxLogLevel level =
+    LogT . local (\e -> e { leMaxLogLevel = level }) . unLogT
 
   getLoggerEnv = LogT ask
