diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,33 @@
-## [_Unreleased_](https://github.com/freckle/freckle-app/compare/v1.1.0.0...main)
+## [_Unreleased_](https://github.com/freckle/freckle-app/compare/v1.2.0.0...main)
+
+## [v1.2.0.0](https://github.com/freckle/freckle-app/compare/v1.1.0.0...v1.2.0.0)
+
+- Remove `Freckle.App.Logging`, integrate with [blammo][] instead.
+
+  This will require the following end-user changes:
+
+  - Remove `HasLogging` instance and add `HasLogger`
+
+    This will require storing a `Logger` on your runtime `App` type, instead of
+    a `LogLevel` (et al) in your static `AppSettings` type. (Or move to
+    `runSimpleLoggingT`.)
+
+  - Replace WAI middleware with [Blammo integration][blammo-wai]
+
+  - Replace Yesod functions with [Blammo integration][blammo-yesod]
+
+  And changes to environment variable values:
+
+  - If using `LOG_OUTPUT=std(out|err)`, use `LOG_DESTINATION=std(out|err)` (note
+    that `stdout` is the default)
+  - If using `LOG_OUTPUT=file`, use `LOG_DESTINATION=@fancy.log` (yes, that's
+    this option did)
+  - If using `LOG_FORMAT=terminal`, use `LOG_FORMAT=tty` (note that this is the
+    defualt)
+
+[blammo]: https://hackage.haskell.org/package/Blammo
+[blammo-wai]: https://github.com/freckle/blammo/#integration-with-wai
+[blammo-yesode]: https://github.com/freckle/blammo/#integration-with-yesod
 
 ## [v1.1.0.0](https://github.com/freckle/freckle-app/compare/v1.0.4.0...v1.1.0.0)
 
diff --git a/freckle-app.cabal b/freckle-app.cabal
--- a/freckle-app.cabal
+++ b/freckle-app.cabal
@@ -1,6 +1,6 @@
 cabal-version:      1.18
 name:               freckle-app
-version:            1.1.0.0
+version:            1.2.0.0
 license:            MIT
 license-file:       LICENSE
 maintainer:         Freckle Education
@@ -37,14 +37,12 @@
         Freckle.App.Http
         Freckle.App.Http.Paginate
         Freckle.App.Http.Retry
-        Freckle.App.Logging
         Freckle.App.Memcached
         Freckle.App.Memcached.CacheKey
         Freckle.App.Memcached.CacheTTL
         Freckle.App.Memcached.Client
         Freckle.App.Memcached.Servers
         Freckle.App.Prelude
-        Freckle.App.RIO
         Freckle.App.Scientist
         Freckle.App.Test
         Freckle.App.Test.DocTest
@@ -70,69 +68,64 @@
         TypeApplications TypeFamilies
 
     build-depends:
-        Glob >=0.9.3,
-        MonadRandom >=0.5.1.1,
-        aeson >=1.3.1.1,
-        ansi-terminal >=0.8.2,
-        base >=4.11.1.0 && <5,
-        bugsnag >=1.0.0.1,
-        bytestring >=0.10.8.2,
-        case-insensitive >=1.2.0.11,
-        conduit >=1.3.1,
-        containers >=0.5.11.0,
-        data-default >=0.7.1.1,
-        datadog >=0.2.3.0,
-        dlist >=0.8.0.5,
-        doctest >=0.16.0.1,
-        ekg-core >=0.1.1.6,
+        Blammo >=1.0.0.1,
+        Glob >=0.10.1,
+        MonadRandom >=0.5.2,
+        aeson >=1.5.2.0,
+        base >=4.13.0.0 && <5,
+        bugsnag >=1.0.0.0,
+        bytestring >=0.10.10.1,
+        case-insensitive >=1.2.1.0,
+        conduit >=1.3.4,
+        containers >=0.6.2.1,
+        datadog >=0.2.5.0,
+        dlist >=0.8.0.8,
+        doctest >=0.16.3,
+        ekg-core >=0.1.1.7,
         envparse >=0.4.1,
         errors >=2.3.0,
-        exceptions >=0.10.0,
-        fast-logger >=2.4.11,
-        filepath >=1.4.2,
-        hashable >=1.2.7.0,
+        exceptions >=0.10.4,
+        filepath >=1.4.2.1,
+        hashable >=1.3.0.0,
         hspec >=2.8.1,
         hspec-core >=2.8.1,
         hspec-expectations-lifted >=0.10.0,
         hspec-junit-formatter >=1.1.0.1,
-        http-client >=0.5.14,
+        http-client >=0.6.4.1,
         http-conduit >=2.3.5,
         http-link-header >=1.0.3.1,
-        http-types >=0.12.2,
+        http-types >=0.12.3,
         immortal >=0.3,
-        iproute >=1.7.7,
-        lens >=4.16.1,
-        load-env >=0.2.0.2,
-        memcache >=0.2.0.1,
+        lens >=4.18.1,
+        load-env >=0.2.1.0,
+        memcache >=0.3.0.1,
         monad-control >=1.0.2.3,
         monad-logger >=0.3.31,
         mtl >=2.2.2,
-        network >=2.6.3.6,
-        network-uri >=2.6.1.0,
-        persistent >=2.8.2,
-        persistent-postgresql >=2.8.2.0,
-        postgresql-simple >=0.5.4.0,
-        primitive >=0.6.3.0,
+        network-uri >=2.6.3.0,
+        persistent >=2.10.5.3,
+        persistent-postgresql >=2.10.1.2,
+        postgresql-simple >=0.6.4,
+        primitive >=0.7.0.1,
         resource-pool >=0.2.3.2,
         retry >=0.8.1.0,
-        rio >=0.1.7.0,
-        safe >=0.3.17,
+        safe >=0.3.19,
         scientist >=0.0.0.0,
-        semigroupoids >=5.2.2,
-        template-haskell >=2.13.0.0,
-        text >=1.2.3.1,
-        time >=1.8.0.2,
-        transformers >=0.5.5.0,
+        semigroupoids >=5.3.4,
+        template-haskell >=2.15.0.0,
+        text >=1.2.4.0,
+        time >=1.9.3,
+        transformers >=0.5.6.2,
         transformers-base >=0.4.5.2,
-        typed-process >=0.2.3.0,
-        unliftio >=0.2.9.0,
-        unordered-containers >=0.2.9.0,
-        vector >=0.12.0.2,
-        wai >=3.2.1.2,
-        wai-extra >=3.0.24.3,
-        yaml >=0.8.32,
-        yesod >=1.6.0,
-        yesod-core >=1.6.9
+        typed-process >=0.2.6.0,
+        unliftio >=0.2.13.1,
+        unliftio-core >=0.1.2.0,
+        unordered-containers >=0.2.10.0,
+        vector >=0.12.1.2,
+        wai >=3.2.2.1,
+        wai-extra >=3.0.32,
+        yaml >=0.11.5.0,
+        yesod-core >=1.6.18.8
 
 test-suite doctest
     type:               exitcode-stdio-1.0
@@ -150,7 +143,7 @@
         TypeApplications TypeFamilies
 
     build-depends:
-        base >=4.11.1.0 && <5,
+        base >=4.13.0.0 && <5,
         freckle-app -any
 
 test-suite gittest
@@ -173,14 +166,14 @@
         TypeApplications TypeFamilies
 
     build-depends:
-        base >=4.11.1.0 && <5,
-        directory >=1.3.1.5,
+        base >=4.13.0.0 && <5,
+        directory >=1.3.6.0,
         freckle-app -any,
         hspec >=2.8.1,
-        process >=1.6.3.0,
+        process >=1.6.9.0,
         temporary >=1.3,
-        text >=1.2.3.1,
-        time >=1.8.0.2
+        text >=1.2.4.0,
+        time >=1.9.3
 
     if flag(test-git)
 
@@ -212,18 +205,17 @@
 
     ghc-options:        -threaded -rtsopts -with-rtsopts=-N
     build-depends:
-        aeson >=1.3.1.1,
-        base >=4.11.1.0 && <5,
-        bytestring >=0.10.8.2,
+        aeson >=1.5.2.0,
+        base >=4.13.0.0 && <5,
+        bytestring >=0.10.10.1,
         errors >=2.3.0,
         freckle-app -any,
         hspec >=2.8.1,
-        http-types >=0.12.2,
-        lens >=4.16.1,
-        lens-aeson >=1.0.2,
-        memcache >=0.2.0.1,
+        http-types >=0.12.3,
+        lens >=4.18.1,
+        lens-aeson >=1.1,
+        memcache >=0.3.0.1,
         mtl >=2.2.2,
-        postgresql-simple >=0.5.4.0,
-        unliftio-core >=0.1.2.0,
-        wai >=3.2.1.2,
-        wai-extra >=3.0.24.3
+        postgresql-simple >=0.6.4,
+        wai >=3.2.2.1,
+        wai-extra >=3.0.32
diff --git a/library/Freckle/App.hs b/library/Freckle/App.hs
--- a/library/Freckle/App.hs
+++ b/library/Freckle/App.hs
@@ -10,19 +10,17 @@
 --
 -- > data App = App
 -- >   { appDryRun :: Bool
--- >   , appLogLevel :: LogLevel
+-- >   , appLogger :: Logger
 -- >   }
 --
 -- This type can be as complex or simple as you want. It might hold a separate
 -- @Config@ attribute or may keep everything as one level of properties. It
 -- could even hold an @'IORef'@ if you need mutable application state.
 --
--- The only requirements are some notion of a @'LogLevel'@:
+-- The only requirements are 'HasLogger':
 --
--- > instance HasLogging App where
--- >   getLogLevel = appLogLevel
--- >   getLogFormat _ = FormatTerminal
--- >   getLogLocation _ = LogStdout
+-- > instance HasLogger App where
+-- >   loggerL = lens appLogger $ \x y -> x { appLogger = y }
 --
 -- and a way to build a value:
 --
@@ -30,11 +28,12 @@
 --
 -- It's likely you'll want to use @"Freckle.App.Env"@ to load your @App@:
 --
+-- > import qualified Blammo.Logger.LogSettings.Env as LoggerEnv
 -- > import qualified Freckle.App.Env as Env
 -- >
 -- > loadApp = Env.parse id $ App
 -- >   <$> Env.switch "DRY_RUN" mempty
--- >   <*> Env.flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty
+-- >   <*> LoggerEnv.parser
 --
 -- Though not required, a type synonym can make things throughout your
 -- application a bit more readable:
@@ -49,7 +48,7 @@
 -- >   isDryRun <- asks appDryRun
 -- >
 -- >   if isDryRun
--- >     then $logWarn "Skipping due to dry-run"
+-- >     then logWarn "Skipping due to dry-run"
 -- >     else liftIO $ fireTheMissles
 --
 -- These actions can be (composed of course, or) invoked by a @main@ that
@@ -70,7 +69,7 @@
 --
 -- > data Config = Config
 -- >   { configDbPoolSize :: Int
--- >   , configLogLevel :: LogLevel
+-- >   , configLogSettings :: LogSettings
 -- >   }
 --
 -- So you can isolate Env-related concerns
@@ -78,19 +77,21 @@
 -- > loadConfig :: IO Config
 -- > loadConfig = Env.parse id $ Config
 -- >   <$> Env.var Env.auto "PGPOOLSIZE" (Env.def 1)
--- >   <*> Env.flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty
+-- >   <*> LoggerEnv.parser
 --
 -- from the runtime application state:
 --
 -- > data App = App
 -- >   { appConfig :: Config
+-- >   , appLogger :: Logger
 -- >   , appSqlPool :: SqlPool
 -- >   }
 -- >
--- > instance HasLogging App where
--- >   getLogLevel = configLogLevel . appConfig
--- >   getLogFormat _ = FormatTerminal
--- >   getLogLocation _ = LogStdout
+-- > instance HasLogger App where
+-- >   loggerL = appLogger $ \x y -> x { appLogger = y }
+-- >
+-- > instance HasSqlPool App where
+-- >   getSqlPool = appSqlPool
 --
 -- The @"Freckle.App.Database"@ module provides @'makePostgresPool'@ for
 -- building a Pool given this (limited) config data:
@@ -98,11 +99,9 @@
 -- > loadApp :: IO App
 -- > loadApp = do
 -- >   appConfig{..} <- loadConfig
--- >   appSqlPool <- makePostgresPool configDbPoolSize
+-- >   appLogger <- newLogger configLoggerSettings
+-- >   appSqlPool <- runLoggerLoggingT appLogger $ makePostgresPool configDbPoolSize
 -- >   pure App{..}
--- >
--- > instance HasSqlPool App where
--- >   getSqlPool = appSqlPool
 --
 -- /Note/: the actual database connection parameters (host, user, etc) are
 -- (currently) parsed from conventional environment variables by the underlying
@@ -145,15 +144,11 @@
 -- Second, we'll probably want a conventional @withApp@ function so that we can
 -- truncate tables as part of spec setup:
 --
--- > import Freckle.App (runApp)
 -- > import Freckle.App.Test hiding (withApp)
 -- > import Test.Hspec
 -- >
 -- > withApp :: SpecWith App -> Spec
--- > withApp = before $ do
--- >   app <- loadApp
--- >   runSqlPool truncateTables $ appSqlPool app
--- >   pure app
+-- > withApp = withAppSql truncateTables loadApp
 --
 -- And now you can write specs that also use the database:
 --
@@ -175,15 +170,14 @@
 
 import Prelude
 
-import Control.Monad.Logger as X
+import Blammo.Logging as X
 import Control.Monad.Reader as X
 import Freckle.App.Database as X
-import Freckle.App.Logging as X
 import System.IO (BufferMode(..), hSetBuffering, stderr, stdout)
 
-runApp :: HasLogging app => app -> ReaderT app (LoggingT IO) a -> IO a
+runApp :: HasLogger app => app -> ReaderT app (LoggingT IO) a -> IO a
 runApp app action = do
   -- Ensure output is streamed if in a Docker container
   hSetBuffering stdout LineBuffering
   hSetBuffering stderr LineBuffering
-  runAppLoggerT app $ runReaderT action app
+  runLoggerLoggingT app $ runReaderT action app
diff --git a/library/Freckle/App/Database.hs b/library/Freckle/App/Database.hs
--- a/library/Freckle/App/Database.hs
+++ b/library/Freckle/App/Database.hs
@@ -22,8 +22,8 @@
 
 import Freckle.App.Prelude
 
+import Blammo.Logging
 import qualified Control.Immortal as Immortal
-import Control.Monad.Logger
 import Control.Monad.Reader
 import Data.ByteString (ByteString)
 import qualified Data.ByteString.Char8 as BS8
@@ -46,6 +46,7 @@
 import qualified Prelude as Unsafe (read)
 import System.Process.Typed (proc, readProcessStdout_)
 import UnliftIO.Concurrent (threadDelay)
+import UnliftIO.Exception (displayException)
 import UnliftIO.IORef
 
 type SqlPool = Pool SqlBackend
@@ -199,10 +200,10 @@
   => PostgresConnectionConf
   -> m (IORef AuroraIamToken)
 spawnIamTokenRefreshThread conf = do
-  logInfoN "Spawning thread to refresh IAM auth token"
+  logInfo "Spawning thread to refresh IAM auth token"
   tokenIORef <- newIORef =<< createAuroraIamToken conf
   void $ Immortal.create $ \_ -> Immortal.onFinish onFinishCallback $ do
-    logDebugN "Refreshing IAM auth token"
+    logDebug "Refreshing IAM auth token"
     refreshIamToken conf tokenIORef
     threadDelay oneMinuteInMicroseconds
   pure tokenIORef
@@ -211,7 +212,9 @@
 
   onFinishCallback = \case
     Left ex ->
-      logErrorN $ pack $ "Error refreshing IAM auth token: " <> show ex
+      logError
+        $ "Error refreshing IAM auth token"
+        :# ["exception" .= displayException ex]
     Right () -> pure ()
 
 refreshIamToken
diff --git a/library/Freckle/App/Datadog.hs b/library/Freckle/App/Datadog.hs
--- a/library/Freckle/App/Datadog.hs
+++ b/library/Freckle/App/Datadog.hs
@@ -3,9 +3,7 @@
 
 -- | Datadog access for your @App@
 module Freckle.App.Datadog
-  (
-  -- * Reader environment interface
-    HasDogStatsClient(..)
+  ( HasDogStatsClient(..)
   , HasDogStatsTags(..)
   , StatsClient
   , Tag
diff --git a/library/Freckle/App/Env.hs b/library/Freckle/App/Env.hs
--- a/library/Freckle/App/Env.hs
+++ b/library/Freckle/App/Env.hs
@@ -51,7 +51,7 @@
 -- If the variable is present and non-empty in the environment, the active value
 -- is returned, otherwise the default is used.
 --
--- >>> import Control.Monad.Logger
+-- >>> import Blammo.Logging (LogLevel(..))
 --
 -- >>> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty `parsePure` [("DEBUG", "1")]
 -- Right LevelDebug
diff --git a/library/Freckle/App/Ghci.hs b/library/Freckle/App/Ghci.hs
--- a/library/Freckle/App/Ghci.hs
+++ b/library/Freckle/App/Ghci.hs
@@ -7,7 +7,7 @@
 
 import Freckle.App.Prelude
 
-import Control.Monad.Logger (runNoLoggingT)
+import Blammo.Logging.Simple
 import Database.Persist.Postgresql (runSqlPool)
 import Database.Persist.Sql (SqlBackend)
 import Freckle.App.Database (makePostgresPool)
@@ -20,7 +20,7 @@
 -- | Run a db action
 runDB' :: ReaderT SqlBackend IO b -> IO b
 runDB' f = do
-  pool <- runNoLoggingT makePostgresPool
+  pool <- runSimpleLoggingT makePostgresPool
   runSqlPool f pool
 
 loadEnvTest :: IO ()
diff --git a/library/Freckle/App/Logging.hs b/library/Freckle/App/Logging.hs
deleted file mode 100644
--- a/library/Freckle/App/Logging.hs
+++ /dev/null
@@ -1,198 +0,0 @@
-{-# LANGUAGE TupleSections #-}
-
-module Freckle.App.Logging
-  (
-  -- * Logging settings
-    HasLogging(..)
-  , getLogDefaultANSI
-  , getLogBehaviors
-  , LogLevel
-  , LogFormat(..)
-  , LogLocation(..)
-
-  -- ** Loading
-  , parseEnvLogFormat
-  , parseEnvLogLevel
-  , parseEnvLogLocation
-
-  -- * 'MonadLogger'-style running
-  , runAppLoggerT
-
-  -- * Formats, for use from other Logging libraries
-  , formatJsonLogStr
-  , formatJsonNoLoc
-  , formatJson
-  , formatTerminal
-  ) where
-
-import Freckle.App.Prelude
-
-import Control.Monad.Logger
-import Data.Aeson (ToJSON, encode, object, (.=))
-import Data.ByteString (ByteString)
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Char8 as BS8
-import qualified Data.ByteString.Lazy as BSL
-import qualified Freckle.App.Env as Env
-import System.Console.ANSI
-  ( Color(Blue, Magenta, Red, Yellow)
-  , ColorIntensity(Dull)
-  , ConsoleLayer(Foreground)
-  , SGR(Reset, SetColor)
-  , hSupportsANSI
-  , setSGRCode
-  )
-import System.IO (stderr, stdout)
-
-data LogFormat
-  = FormatJSON
-  -- ^ Emit @{"level": "{level}", "message": "{message}"}@
-  | FormatTerminal
-  -- ^ Emit @[{level}] @{message}@, possibly colorized
-
-data LogLocation
-  = LogStdout
-  | LogStderr
-  | LogFile FilePath
-
--- | Class for getting Logging settings from your @app@ type
-class HasLogging a where
-    getLogLevel :: a -> LogLevel
-
-    getLogFormat :: a -> LogFormat
-
-    getLogLocation :: a -> LogLocation
-
--- | Provide a pure decision for colorizing output
---
--- This is useful in a context where actively checking for ANSI terminal support
--- is either not possible or too expensive. Given that we support 'LogFile', and
--- so are unlikely to be redirecting terminal output to a file, it is relatively
--- safe to use this determination.
---
-getLogDefaultANSI :: HasLogging a => a -> Bool
-getLogDefaultANSI app = case (getLogLocation app, getLogFormat app) of
-  (LogStdout, FormatTerminal) -> True
-  (LogStdout, FormatJSON) -> False
-  (LogStderr, FormatTerminal) -> True
-  (LogStderr, FormatJSON) -> False
-  (LogFile _, FormatTerminal) -> False
-  (LogFile _, FormatJSON) -> False
-
-getLogBehaviors :: HasLogging a => a -> IO (ByteString -> IO (), Bool)
-getLogBehaviors app = case getLogLocation app of
-  LogStdout -> (BS8.hPutStr stdout, ) <$> hSupportsANSI stdout
-  LogStderr -> (BS8.hPutStr stderr, ) <$> hSupportsANSI stderr
-  LogFile path -> pure (BS8.appendFile path, False)
-
-parseEnvLogLevel :: Env.Parser Env.Error LogLevel
-parseEnvLogLevel = Env.var parse "LOG_LEVEL" $ Env.def LevelWarn
- where
-  parse = Env.eitherReader $ \case
-    "warn" -> Right LevelWarn
-    "error" -> Right LevelError
-    "debug" -> Right LevelDebug
-    "info" -> Right LevelInfo
-    level -> Left $ "unexpected log level: " <> level
-
-parseEnvLogFormat :: Env.Parser Env.Error LogFormat
-parseEnvLogFormat = Env.var parse "LOG_FORMAT" $ Env.def FormatTerminal
- where
-  parse = Env.eitherReader $ \case
-    "json" -> Right FormatJSON
-    "terminal" -> Right FormatTerminal
-    format -> Left $ "unexpected format: " <> format
-
-parseEnvLogLocation :: Env.Parser Env.Error LogLocation
-parseEnvLogLocation = Env.var parse "LOG_LOCATION" $ Env.def LogStdout
- where
-  parse = Env.eitherReader $ \case
-    "stdout" -> Right LogStdout
-    "stderr" -> Right LogStderr
-    "file" -> Right $ LogFile "fancy.log"
-    file -> Right $ LogFile file
-
-runAppLoggerT :: HasLogging a => a -> LoggingT IO b -> IO b
-runAppLoggerT app f = do
-  (putLogLine, isANSI) <- getLogBehaviors app
-
-  let
-    logger = case getLogFormat app of
-      FormatJSON -> jsonLogger putLogLine
-      FormatTerminal -> ansiLogger putLogLine isANSI
-
-  flip runLoggingT logger
-    $ filterLogger (\_ level -> level >= getLogLevel app) f
- where
-  jsonLogger putLogLine loc src level str =
-    putLogLine $ formatJsonLogStr loc src level str
-
-  ansiLogger putLogLine isANSI loc src level str =
-    putLogLine $ formatTerminal isANSI loc src level str
-
-formatJsonLogStr :: Loc -> LogSource -> LogLevel -> LogStr -> ByteString
-formatJsonLogStr loc src level =
-  formatJson (Just loc) (Just src) level . decodeUtf8 . fromLogStr
-
-formatJsonNoLoc :: ToJSON a => LogLevel -> a -> ByteString
-formatJsonNoLoc = formatJson Nothing Nothing
-
-formatJson
-  :: ToJSON a => Maybe Loc -> Maybe LogSource -> LogLevel -> a -> ByteString
-formatJson loc src level msg = (<> "\n") $ BSL.toStrict $ encode $ object
-  [ "loc" .= (locJson <$> loc)
-  , "src" .= src
-  , "level" .= levelText level
-  , "message" .= msg
-  ]
- where
-  locJson Loc {..} = object
-    [ "filename" .= loc_filename
-    , "package" .= loc_package
-    , "module" .= loc_module
-    , "start" .= loc_start
-    , "end" .= loc_end
-    ]
-
-formatTerminal
-  :: ToLogStr a
-  => Bool -- ^ Supports escapes?
-  -> Loc
-  -> LogSource
-  -> LogLevel
-  -> a
-  -> ByteString
-formatTerminal isANSI loc src level str = mconcat
-  [ esc $ style level
-  , BS.snoc levelStr labelEnd
-  , esc Reset
-  , BS.intercalate (BS.singleton labelEnd) logStr
-  , esc Reset
-  ]
- where
-  labelEnd = fromIntegral $ fromEnum ']'
-
-  (levelStr, logStr) =
-    let formatted = fromLogStr $ defaultLogStr loc src level $ toLogStr str
-    in
-      case BS.split labelEnd formatted of
-        [] -> ("", [formatted])
-        (x : xs) -> (x, xs)
-
-  esc x = if isANSI then BS8.pack $ setSGRCode [x] else ""
-
-style :: LogLevel -> SGR
-style = \case
-  LevelDebug -> SetColor Foreground Dull Magenta
-  LevelInfo -> SetColor Foreground Dull Blue
-  LevelWarn -> SetColor Foreground Dull Yellow
-  LevelError -> SetColor Foreground Dull Red
-  LevelOther _ -> Reset
-
-levelText :: LogLevel -> Text
-levelText = \case
-  LevelDebug -> "Debug"
-  LevelInfo -> "Info"
-  LevelWarn -> "Warn"
-  LevelError -> "Error"
-  LevelOther x -> x
diff --git a/library/Freckle/App/Memcached.hs b/library/Freckle/App/Memcached.hs
--- a/library/Freckle/App/Memcached.hs
+++ b/library/Freckle/App/Memcached.hs
@@ -23,7 +23,7 @@
 
 import Freckle.App.Prelude
 
-import Control.Monad.Logger (MonadLogger, logErrorN)
+import Blammo.Logging
 import Data.Aeson
 import Data.ByteString (ByteString)
 import qualified Data.ByteString.Lazy as BSL
@@ -119,7 +119,10 @@
 
 logCachingError :: MonadLogger m => Text -> Text -> m ()
 logCachingError action message =
-  logErrorN $ "[Caching] error " <> action <> ": " <> message
+  logErrorNS "caching"
+    $ "Error "
+    <> action
+    :# ["action" .= action, "message" .= message]
 
 encodeStrict :: ToJSON a => a -> ByteString
 encodeStrict = BSL.toStrict . encode
diff --git a/library/Freckle/App/RIO.hs b/library/Freckle/App/RIO.hs
deleted file mode 100644
--- a/library/Freckle/App/RIO.hs
+++ /dev/null
@@ -1,71 +0,0 @@
--- | Compatibility between "Freckle.App" and "RIO"
---
--- "Freckle.App" was created before "RIO" existed. We need to decide if we're
--- going to move to using "RIO" without "Freckle.App" (and port the things
--- we've added to be "RIO"-based), or not.
---
--- As part of that decisions, some Apps are using "RIO". These should still be
--- able to make use of "Freckle.App", by using this module.
---
-module Freckle.App.RIO
-  ( toAppLogLevel
-  , fromAppLogLevel
-  , makeLogFunc
-  ) where
-
-import Freckle.App.Prelude
-
-import Control.Monad.Logger (Loc(..), LogLevel(..))
-import Freckle.App.Logging
-import GHC.Exception (CallStack, SrcLoc(..), getCallStack)
-import qualified RIO
-
-toAppLogLevel :: RIO.LogLevel -> LogLevel
-toAppLogLevel = \case
-  RIO.LevelDebug -> LevelDebug
-  RIO.LevelInfo -> LevelInfo
-  RIO.LevelWarn -> LevelWarn
-  RIO.LevelError -> LevelError
-  RIO.LevelOther x -> LevelOther x
-
-fromAppLogLevel :: LogLevel -> RIO.LogLevel
-fromAppLogLevel = \case
-  LevelDebug -> RIO.LevelDebug
-  LevelInfo -> RIO.LevelInfo
-  LevelWarn -> RIO.LevelWarn
-  LevelError -> RIO.LevelError
-  LevelOther x -> RIO.LevelOther x
-
-makeLogFunc :: HasLogging a => a -> IO RIO.LogFunc
-makeLogFunc app = do
-  (putLogLine, isANSI) <- getLogBehaviors app
-
-  pure $ RIO.mkLogFunc $ \cs src rioLevel builder -> do
-    let
-      level = toAppLogLevel rioLevel
-      msg = RIO.utf8BuilderToText builder
-
-    when (level >= getLogLevel app) $ putLogLine $ case getLogFormat app of
-      FormatJSON -> formatJson (Just $ callStackToLoc cs) (Just src) level msg
-      FormatTerminal -> formatTerminal isANSI (callStackToLoc cs) src level msg
-
-callStackToLoc :: CallStack -> Loc
-callStackToLoc cs = fromMaybe unknownLoc $ do
-  (_, SrcLoc {..}) <- listToMaybe $ getCallStack cs
-
-  pure $ Loc
-    { loc_filename = srcLocFile
-    , loc_package = srcLocPackage
-    , loc_module = srcLocModule
-    , loc_start = (srcLocStartLine, srcLocStartCol)
-    , loc_end = (srcLocEndLine, srcLocEndCol)
-    }
-
-unknownLoc :: Loc
-unknownLoc = Loc
-  { loc_filename = "<unknown>"
-  , loc_package = "<unknown>"
-  , loc_module = "unknown"
-  , loc_start = (0, 0)
-  , loc_end = (0, 0)
-  }
diff --git a/library/Freckle/App/Test.hs b/library/Freckle/App/Test.hs
--- a/library/Freckle/App/Test.hs
+++ b/library/Freckle/App/Test.hs
@@ -4,7 +4,7 @@
 {-# OPTIONS_GHC -fno-warn-deprecations #-}
 
 module Freckle.App.Test
-  ( AppExample
+  ( AppExample(..)
   , withApp
   , withAppSql
   , runAppTest
@@ -13,16 +13,18 @@
 
 import Freckle.App.Prelude
 
+import Blammo.Logging
 import Control.Monad.Base
 import Control.Monad.Catch
 import qualified Control.Monad.Fail as Fail
-import Control.Monad.Logger
+import Control.Monad.IO.Unlift (MonadUnliftIO(..))
 import Control.Monad.Primitive
 import Control.Monad.Random (MonadRandom(..))
 import Control.Monad.Reader
 import Control.Monad.Trans.Control
 import Data.Pool as X
 import Database.Persist.Sql (SqlPersistT, runSqlPool)
+import Freckle.App (runApp)
 import Freckle.App.Database as X
 import LoadEnv
 import Test.Hspec as X
@@ -31,7 +33,15 @@
 import Test.Hspec.Expectations.Lifted as X
 
 -- | An Hspec example over some @App@ value
-newtype AppExample app a = AppExample (NoLoggingT (ReaderT app IO) a)
+--
+-- To disable logging in tests, you can either:
+--
+-- - Export @LOG_LEVEL=error@, if this would be quiet enough, or
+-- - Export @LOG_DESTINATION=@/dev/null@ to fully silence
+--
+newtype AppExample app a = AppExample
+  { unAppExample :: ReaderT app (LoggingT IO) a
+  }
   deriving newtype
     ( Applicative
     , Functor
@@ -42,11 +52,21 @@
     , MonadIO
     , MonadReader app
     , MonadThrow
-    , MonadUnliftIO
     , MonadLogger
     , Fail.MonadFail
     )
 
+-- We could derive this in newer versions of unliftio-core, but defining it by
+-- hand supports a few resolvers back, without CPP. This is just a copy of the
+-- ReaderT instance,
+--
+-- https://hackage.haskell.org/package/unliftio-core-0.2.0.1/docs/src/Control.Monad.IO.Unlift.html#line-64
+--
+instance MonadUnliftIO (AppExample app) where
+  {-# INLINE withRunInIO #-}
+  withRunInIO inner =
+    AppExample $ withRunInIO $ \run -> inner (run . unAppExample)
+
 instance MonadRandom (AppExample app) where
   getRandomR = liftIO . getRandomR
   getRandom = liftIO getRandom
@@ -57,11 +77,11 @@
   type PrimState (AppExample app) = PrimState IO
   primitive = liftIO . primitive
 
-instance Example (AppExample app a) where
+instance HasLogger app => Example (AppExample app a) where
   type Arg (AppExample app a) = app
 
   evaluateExample (AppExample ex) params action = evaluateExample
-    (action $ \app -> void $ runReaderT (runNoLoggingT ex) app)
+    (action $ \app -> void $ runLoggerLoggingT app $ runReaderT ex app)
     params
     ($ ())
 
@@ -73,7 +93,8 @@
 -- @
 --
 -- Reads @.env.test@, then @.env@, then loads the application. Examples within
--- this spec can use 'runAppTest' (and 'runDB', if the app 'HasSqlPool').
+-- this spec can use 'runAppTest' if the app 'HasLogger' (and 'runDB', if the
+-- app 'HasSqlPool').
 --
 withApp :: IO app -> SpecWith app -> Spec
 withApp load = beforeAll (loadEnvTest *> load)
@@ -92,13 +113,7 @@
 loadEnvTest = loadEnvFrom ".env.test" >> loadEnv
 
 -- | Run an action with the test @App@
---
--- Like @'runApp'@, but without exception handling or logging
---
-runAppTest :: ReaderT app (LoggingT IO) a -> AppExample app a
+runAppTest :: HasLogger app => ReaderT app (LoggingT IO) a -> AppExample app a
 runAppTest action = do
   app <- ask
-
-  liftIO $ runStderrLoggingT $ filterLogger (\_ _ -> False) $ runReaderT
-    action
-    app
+  liftIO $ runApp app action
diff --git a/library/Freckle/App/Test/Logging.hs b/library/Freckle/App/Test/Logging.hs
--- a/library/Freckle/App/Test/Logging.hs
+++ b/library/Freckle/App/Test/Logging.hs
@@ -4,6 +4,8 @@
   ( MonadLogger
   , LoggingT
   , runCapturedLoggingT
+  , logLineToJSON
+  , logLineToByteString
   , logLineToText
   ) where
 
@@ -11,6 +13,9 @@
 
 import Control.Concurrent.Chan
 import Control.Monad.Logger
+import Data.Aeson
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Lazy as BSL
 import Data.DList (DList)
 import qualified Data.DList as DList
 import UnliftIO.Async
@@ -42,5 +47,11 @@
 doneMessage :: Text
 doneMessage = "%DONE%"
 
+logLineToJSON :: FromJSON a => LogLine -> Maybe a
+logLineToJSON = decode . BSL.fromStrict . logLineToByteString
+
+logLineToByteString :: LogLine -> ByteString
+logLineToByteString (_, _, _, str) = fromLogStr str
+
 logLineToText :: LogLine -> Text
-logLineToText (_, _, _, str) = decodeUtf8 $ fromLogStr str
+logLineToText = decodeUtf8 . logLineToByteString
diff --git a/library/Freckle/App/Wai.hs b/library/Freckle/App/Wai.hs
--- a/library/Freckle/App/Wai.hs
+++ b/library/Freckle/App/Wai.hs
@@ -1,7 +1,6 @@
 -- | Integration of "Freckle.App" tooling with "Network.Wai"
 module Freckle.App.Wai
-  ( makeLoggingMiddleware
-  , makeRequestMetricsMiddleware
+  ( makeRequestMetricsMiddleware
   , noCacheMiddleware
   , corsMiddleware
   , denyFrameEmbeddingMiddleware
@@ -9,142 +8,19 @@
 
 import Freckle.App.Prelude hiding (decodeUtf8)
 
-import Control.Monad.Logger (LogLevel(..))
 import Control.Monad.Reader (runReaderT)
-import Data.Aeson
 import Data.ByteString (ByteString)
 import qualified Data.ByteString as BS
-import Data.ByteString.Builder (toLazyByteString)
-import qualified Data.ByteString.Char8 as BS8
-import qualified Data.ByteString.Lazy as BSL
 import qualified Data.CaseInsensitive as CI
-import Data.Default (def)
-import Data.IP (fromHostAddress, fromHostAddress6)
-import Data.String (fromString)
 import Data.Text.Encoding (decodeUtf8With)
 import Data.Text.Encoding.Error (lenientDecode)
 import Freckle.App.Datadog (HasDogStatsClient, HasDogStatsTags)
 import qualified Freckle.App.Datadog as Datadog
-import Freckle.App.Logging
-import Network.HTTP.Types (QueryItem, ResponseHeaders)
-import Network.HTTP.Types.Status (Status, status200, statusCode)
-import Network.Socket
+import Network.HTTP.Types (ResponseHeaders)
+import Network.HTTP.Types.Status (status200, statusCode)
 import Network.Wai
 import Network.Wai.Middleware.AddHeaders (addHeaders)
-import Network.Wai.Middleware.RequestLogger
-  ( Destination(Logger)
-  , OutputFormat(..)
-  , OutputFormatterWithDetails
-  , destination
-  , mkRequestLogger
-  , outputFormat
-  )
-import System.Log.FastLogger (LoggerSet, toLogStr)
 
-makeLoggingMiddleware
-  :: HasLogging app
-  => app
-  -> (Request -> [(Text, Text)])
-  -> LoggerSet
-  -> IO Middleware
-makeLoggingMiddleware app getTags ls = case getLogFormat app of
-  FormatJSON ->
-    makeWith
-      $ CustomOutputFormatWithDetails
-      $ suppressByStatus (getLogLevel app)
-      $ jsonOutputFormatter getTags
-  FormatTerminal -> makeWith $ Detailed $ getLogDefaultANSI app
- where
-  makeWith format =
-    mkRequestLogger def { outputFormat = format, destination = Logger ls }
-
-suppressByStatus
-  :: LogLevel -> OutputFormatterWithDetails -> OutputFormatterWithDetails
-suppressByStatus minLevel f date req status responseSize duration reqBody response
-  | statusLevel status >= minLevel
-  = f date req status responseSize duration reqBody response
-  | otherwise
-  = ""
-
-jsonOutputFormatter
-  :: (Request -> [(Text, Text)]) -> OutputFormatterWithDetails
-jsonOutputFormatter getTags date req status responseSize duration _reqBody response
-  = toLogStr
-    $ formatJsonNoLoc (statusLevel status)
-    $ object
-    $ [ "time" .= decodeUtf8 date
-      , "method" .= decodeUtf8 (requestMethod req)
-      , "path" .= decodeUtf8 (rawPathInfo req)
-      , "query_string" .= map queryItemToJSON (queryString req)
-      , "status" .= statusCode status
-      , "duration_ms" .= (duration * 1000)
-      , "request_size" .= requestBodyLengthToJSON (requestBodyLength req)
-      , "response_size" .= responseSize
-      , "response_body" .= do
-        guard $ statusCode status >= 400
-        Just $ maybeDecodeToValue $ toLazyByteString response
-      , "client_ip" .= (decodeUtf8 <$> clientIp)
-      ]
-    <> map (uncurry (.=) . first (fromString . unpack)) (getTags req)
-  where clientIp = requestRealIp req <|> Just (sockAddrToIp $ remoteHost req)
-
-statusLevel :: Status -> LogLevel
-statusLevel status = case statusCode status of
-  404 -> LevelInfo -- Special case
-  code | code >= 500 -> LevelError
-  code | code >= 400 -> LevelWarn
-  code | code >= 300 -> LevelInfo
-  _ -> LevelDebug
-
-decodeUtf8 :: ByteString -> Text
-decodeUtf8 = decodeUtf8With lenientDecode
-
-requestBodyLengthToJSON :: RequestBodyLength -> Value
-requestBodyLengthToJSON ChunkedBody = String "Unknown"
-requestBodyLengthToJSON (KnownLength l) = toJSON l
-
-queryItemToJSON :: QueryItem -> Value
-queryItemToJSON (name, mValue) =
-  toJSON (decodeUtf8 name, decodeUtf8 <$> mValue)
-
--- Try to decode as a 'Value'. Otherwise make a JSON string.
-maybeDecodeToValue :: BSL.ByteString -> Value
-maybeDecodeToValue str =
-  fromMaybe (toJSON . decodeUtf8With lenientDecode . BSL.toStrict $ str)
-    . decode @Value
-    $ str
-
--- Copied from bugnag-haskell
-
-requestRealIp :: Request -> Maybe ByteString
-requestRealIp request =
-  requestForwardedFor request <|> lookup "X-Real-IP" (requestHeaders request)
-
-requestForwardedFor :: Request -> Maybe ByteString
-requestForwardedFor request =
-  readForwardedFor =<< lookup "X-Forwarded-For" (requestHeaders request)
-
--- |
---
--- >>> readForwardedFor ""
--- Nothing
---
--- >>> readForwardedFor "123.123.123"
--- Just "123.123.123"
---
--- >>> readForwardedFor "123.123.123, 45.45.45"
--- Just "123.123.123"
---
-readForwardedFor :: ByteString -> Maybe ByteString
-readForwardedFor bs
-  | BS8.null bs = Nothing
-  | otherwise = Just $ fst $ BS8.break (== ',') bs
-
-sockAddrToIp :: SockAddr -> ByteString
-sockAddrToIp (SockAddrInet _ h) = BS8.pack $ show $ fromHostAddress h
-sockAddrToIp (SockAddrInet6 _ _ h _) = BS8.pack $ show $ fromHostAddress6 h
-sockAddrToIp _ = "<socket>"
-
 makeRequestMetricsMiddleware
   :: (HasDogStatsClient env, HasDogStatsTags env)
   => env
@@ -224,3 +100,6 @@
 
   exposedHeaders =
     ["Set-Cookie", "Content-Disposition", "Link"] <> extraExposedHeaders
+
+decodeUtf8 :: ByteString -> Text
+decodeUtf8 = decodeUtf8With lenientDecode
diff --git a/library/Freckle/App/Yesod.hs b/library/Freckle/App/Yesod.hs
--- a/library/Freckle/App/Yesod.hs
+++ b/library/Freckle/App/Yesod.hs
@@ -1,67 +1,19 @@
 -- | Integration of "Freckle.App" tooling with "Yesod"
 module Freckle.App.Yesod
-  ( makeLogger
-  , messageLoggerSource
-
-  -- * Functions for use as 'yesodMiddleware'
-  , respondQueryCanceled
+  ( respondQueryCanceled
   , respondQueryCanceledHeaders
   ) where
 
 import Freckle.App.Prelude
 
-import Control.Monad.Logger
+import Blammo.Logging
 import Database.PostgreSQL.Simple (SqlError(..))
 import Freckle.App.Datadog (HasDogStatsClient, HasDogStatsTags)
 import qualified Freckle.App.Datadog as Datadog
-import Freckle.App.GlobalCache
-import Freckle.App.Logging
 import Network.HTTP.Types (ResponseHeaders, status503)
 import qualified Network.Wai as W
-import System.IO.Unsafe (unsafePerformIO)
-import System.Log.FastLogger
-  ( LoggerSet
-  , defaultBufSize
-  , newFileLoggerSet
-  , newStderrLoggerSet
-  , newStdoutLoggerSet
-  )
-import UnliftIO.Exception (handleJust)
-import Yesod.Core.Handler (sendWaiResponse)
-import Yesod.Core.Types (HandlerFor, Logger, loggerPutStr)
-import Yesod.Default.Config2 (makeYesodLogger)
-
-loggerSetVar :: GlobalCache LoggerSet
-loggerSetVar = unsafePerformIO newGlobalCache
-{-# NOINLINE loggerSetVar #-}
-{-# ANN loggerSetVar ("HLint: ignore Avoid restricted function" :: String) #-}
-
-makeLogger :: HasLogging a => a -> IO Logger
-makeLogger app = makeYesodLogger
-  =<< globallyCache loggerSetVar (newLoggerSet defaultBufSize)
- where
-  newLoggerSet = case getLogLocation app of
-    LogStdout -> newStdoutLoggerSet
-    LogStderr -> newStderrLoggerSet
-    LogFile f -> flip newFileLoggerSet f
-
-messageLoggerSource
-  :: HasLogging a
-  => a
-  -> Logger
-  -> Loc
-  -> LogSource
-  -> LogLevel
-  -> LogStr
-  -> IO ()
-messageLoggerSource app logger loc src level str =
-  when (level >= getLogLevel app)
-    $ loggerPutStr logger
-    $ toLogStr
-    $ case getLogFormat app of
-        FormatJSON -> formatJsonLogStr loc src level str
-        FormatTerminal ->
-          formatTerminal (getLogDefaultANSI app) loc src level str
+import UnliftIO.Exception (displayException, handleJust)
+import Yesod.Core.Handler (HandlerFor, sendWaiResponse)
 
 -- | Catch 'SqlError' when queries are canceled due to timeout and respond 503
 --
@@ -80,7 +32,7 @@
   -> HandlerFor site res
   -> HandlerFor site res
 respondQueryCanceledHeaders headers = handleJust queryCanceled $ \ex -> do
-  logErrorN $ pack $ show ex
+  logError $ "Query canceled" :# ["exception" .= displayException ex]
   Datadog.increment "query_canceled" []
   sendWaiResponse $ W.responseLBS status503 headers "Query canceled"
 
diff --git a/package.yaml b/package.yaml
--- a/package.yaml
+++ b/package.yaml
@@ -1,6 +1,6 @@
 ---
 name: freckle-app
-version: 1.1.0.0
+version: 1.2.0.0
 maintainer: Freckle Education
 category: Utils
 github: freckle/freckle-app
@@ -52,16 +52,15 @@
 library:
   source-dirs: library
   dependencies:
+    - Blammo
     - Glob
     - MonadRandom
     - aeson
-    - ansi-terminal
     - bugsnag
     - bytestring
     - case-insensitive
     - conduit
     - containers
-    - data-default
     - datadog
     - dlist
     - doctest
@@ -69,7 +68,6 @@
     - envparse
     - errors
     - exceptions
-    - fast-logger
     - filepath
     - hashable
     - hspec >= 2.8.1
@@ -81,14 +79,12 @@
     - http-link-header
     - http-types
     - immortal
-    - iproute
     - lens
     - load-env
     - memcache
     - monad-control
     - monad-logger >= 0.3.31
     - mtl
-    - network
     - network-uri
     - persistent
     - persistent-postgresql
@@ -96,7 +92,6 @@
     - primitive
     - resource-pool
     - retry >= 0.8.1.0
-    - rio
     - safe
     - scientist
     - semigroupoids
@@ -107,13 +102,13 @@
     - transformers-base
     - typed-process
     - unliftio
+    - unliftio-core
     - unordered-containers
     - vector
     - wai
     - wai-extra
     - yaml
     - yesod-core
-    - yesod
 
 tests:
   spec:
@@ -132,7 +127,6 @@
       - memcache
       - mtl
       - postgresql-simple
-      - unliftio-core
       - wai
       - wai-extra
 
diff --git a/tests/Freckle/App/MemcachedSpec.hs b/tests/Freckle/App/MemcachedSpec.hs
--- a/tests/Freckle/App/MemcachedSpec.hs
+++ b/tests/Freckle/App/MemcachedSpec.hs
@@ -1,19 +1,23 @@
+{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
+
 module Freckle.App.MemcachedSpec
   ( spec
   ) where
 
 import Freckle.App.Prelude
 
-import Control.Monad.IO.Unlift (MonadUnliftIO(..))
+import Control.Lens ((^?!))
 import Control.Monad.Reader
+import Data.Aeson
+import Data.Aeson.Lens
 import qualified Data.List.NonEmpty as NE
 import qualified Freckle.App.Env as Env
 import Freckle.App.Memcached
 import Freckle.App.Memcached.Client (MemcachedClient, newMemcachedClient)
 import qualified Freckle.App.Memcached.Client as Memcached
 import Freckle.App.Memcached.Servers
+import Freckle.App.Test
 import Freckle.App.Test.Logging
-import Test.Hspec
 
 data ExampleValue
   = A
@@ -33,63 +37,56 @@
     "C" -> Right C
     x -> Left $ "invalid: " <> show x
 
-newtype TestAppT m a = TestAppT
-  { unTestAppT :: ReaderT MemcachedClient (LoggingT m) a
-  }
-  deriving newtype
-    ( Functor
-    , Applicative
-    , Monad
-    , MonadReader MemcachedClient
-    , MonadIO
-    , MonadLogger
-    )
-
--- We could derive this in newer versions of unliftio-core, but defining it by
--- hand supports a few resolvers back, without CPP. This is just a copy of the
--- ReaderT instance,
---
--- https://hackage.haskell.org/package/unliftio-core-0.2.0.1/docs/src/Control.Monad.IO.Unlift.html#line-64
---
-instance MonadUnliftIO m => MonadUnliftIO (TestAppT m) where
-  {-# INLINE withRunInIO #-}
-  withRunInIO inner = TestAppT $ withRunInIO $ \run -> inner (run . unTestAppT)
+runTestAppT
+  :: MonadUnliftIO m => AppExample MemcachedClient a -> m (a, [Maybe Value])
+runTestAppT f = liftIO $ do
+  mc <- loadClient
+  -- NB. we could use `withApp` and not need to call this runner ourselves
+  -- within a plain-`IO` example -- except that we want to use
+  -- `runCapturedLoggingT` and assert on the logged messages. We should add
+  -- Blammo.Logging.Test to support this use-case.
+  fmap (second $ map logLineToJSON) $ runCapturedLoggingT $ runReaderT
+    (unAppExample f)
+    mc
 
-runTestAppT :: MonadUnliftIO m => TestAppT m a -> m (a, [Text])
-runTestAppT f = do
-  servers <- liftIO $ Env.parse id $ Env.var
+loadClient :: IO MemcachedClient
+loadClient = do
+  servers <- Env.parse id $ Env.var
     (Env.eitherReader readMemcachedServers)
     "MEMCACHED_SERVERS"
     (Env.def defaultMemcachedServers)
-  mc <- newMemcachedClient servers
-  fmap (second $ map logLineToText) $ runCapturedLoggingT $ runReaderT
-    (unTestAppT f)
-    mc
+  newMemcachedClient servers
 
 spec :: Spec
 spec = do
   describe "caching" $ do
     it "caches the given action by key using Cachable" $ example $ do
       void $ runTestAppT $ do
-        key <- cacheKeyThrow "A"
+        k <- cacheKeyThrow "A"
 
-        val <- caching key (cacheTTL 5) $ pure A
-        mbs <- Memcached.get key
+        val <- caching k (cacheTTL 5) $ pure A
+        mbs <- Memcached.get k
 
         liftIO $ val `shouldBe` A
         liftIO $ mbs `shouldBe` Just "A"
 
     it "logs, but doesn't fail, on deserialization errors" $ example $ do
       (_, msgs) <- runTestAppT $ do
-        key <- cacheKeyThrow "B"
+        k <- cacheKeyThrow "B"
 
-        val0 <- caching key (cacheTTL 5) $ pure B -- set
-        val1 <- caching key (cacheTTL 5) $ pure B -- get will fail
-        mbs <- Memcached.get key
+        val0 <- caching k (cacheTTL 5) $ pure B -- set
+        val1 <- caching k (cacheTTL 5) $ pure B -- get will fail
+        mbs <- Memcached.get k
 
         liftIO $ val0 `shouldBe` B
         liftIO $ val1 `shouldBe` B
         liftIO $ mbs `shouldBe` Just "Broken"
 
-      fmap NE.last (NE.nonEmpty msgs)
-        `shouldBe` Just "[Caching] error deserializing: invalid: \"Broken\""
+      let Just val = NE.last =<< NE.nonEmpty msgs
+      val ^?! key "text" . _String `shouldBe` "Error deserializing"
+      val ^?! key "meta" . key "action" . _String `shouldBe` "deserializing"
+      val
+        ^?! key "meta"
+        . key "message"
+        . _String
+        `shouldBe` "invalid: \"Broken\""
