diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,8 @@
-## [_Unreleased_](https://github.com/freckle/freckle-app/compare/v1.9.0.3...main)
+## [_Unreleased_](https://github.com/freckle/freckle-app/compare/v1.9.1.0...main)
+
+## [v1.9.1.0](https://github.com/freckle/freckle-app/compare/v1.9.0.3...v1.9.1.0)
+
+- Add `Freckle.App.Kafka` for producing kafka events
 
 ## [v1.9.0.3](https://github.com/freckle/freckle-app/compare/v1.9.0.2...v1.9.0.3)
 
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.9.0.3
+version:            1.9.1.0
 license:            MIT
 license-file:       LICENSE
 maintainer:         Freckle Education
@@ -36,6 +36,7 @@
         Freckle.App.Http
         Freckle.App.Http.Paginate
         Freckle.App.Http.Retry
+        Freckle.App.Kafka
         Freckle.App.Memcached
         Freckle.App.Memcached.CacheKey
         Freckle.App.Memcached.CacheTTL
@@ -110,6 +111,7 @@
         http-conduit >=2.3.5,
         http-link-header,
         http-types,
+        hw-kafka-client,
         immortal,
         lens,
         memcache,
diff --git a/library/Freckle/App.hs b/library/Freckle/App.hs
--- a/library/Freckle/App.hs
+++ b/library/Freckle/App.hs
@@ -153,16 +153,15 @@
 -- >     it "works" $ do
 -- >       result <- runDB myQuery :: AppExample App Text
 -- >       result `shouldBe` "as expected"
---
 module Freckle.App
   ( runApp
   , setLineBuffering
 
-  -- * Concrete transformer stack
-  , AppT(..)
+    -- * Concrete transformer stack
+  , AppT (..)
   , runAppT
 
-  -- * Re-exports
+    -- * Re-exports
   , module Freckle.App.Database
   , module Freckle.App.OpenTelemetry
   , module Blammo.Logging
@@ -173,17 +172,17 @@
 
 import Blammo.Logging
 import Control.Monad.Catch (MonadCatch, MonadThrow)
-import Control.Monad.IO.Unlift (MonadUnliftIO(..))
-import Control.Monad.Primitive (PrimMonad(..))
+import Control.Monad.IO.Unlift (MonadUnliftIO (..))
+import Control.Monad.Primitive (PrimMonad (..))
 import Control.Monad.Reader
 import Control.Monad.Trans.Resource (MonadResource, ResourceT, runResourceT)
 import Freckle.App.Database
 import Freckle.App.OpenTelemetry
-import System.IO (BufferMode(..), hSetBuffering, stderr, stdout)
+import System.IO (BufferMode (..), hSetBuffering, stderr, stdout)
 
 runApp
   :: HasLogger app
-  => (forall b . (app -> IO b) -> IO b)
+  => (forall b. (app -> IO b) -> IO b)
   -> AppT app IO a
   -> IO a
 runApp loadApp action = do
@@ -194,7 +193,6 @@
 --
 -- 'runApp' calls this for you, but it may be useful if you're running the app
 -- some other way.
---
 setLineBuffering :: MonadIO m => m ()
 setLineBuffering = liftIO $ do
   hSetBuffering stdout LineBuffering
diff --git a/library/Freckle/App/Aeson.hs b/library/Freckle/App/Aeson.hs
--- a/library/Freckle/App/Aeson.hs
+++ b/library/Freckle/App/Aeson.hs
@@ -5,7 +5,6 @@
 -- This should be its own package, but the obvious name (@aeson-compat@) is
 -- taken by something old and unrelated. I think that's why no one is doing it
 -- yet, including us.
---
 module Freckle.App.Aeson
   ( module X
   ) where
diff --git a/library/Freckle/App/Bugsnag.hs b/library/Freckle/App/Bugsnag.hs
--- a/library/Freckle/App/Bugsnag.hs
+++ b/library/Freckle/App/Bugsnag.hs
@@ -1,20 +1,20 @@
 module Freckle.App.Bugsnag
   ( Settings
-  , HasBugsnagSettings(..)
+  , HasBugsnagSettings (..)
   , notifyBugsnag
   , notifyBugsnagWith
 
-  -- * 'AppVersion'
-  , HasAppVersion(..)
+    -- * 'AppVersion'
+  , HasAppVersion (..)
   , setAppVersion
 
-  -- * Loading settings
+    -- * Loading settings
   , envParseBugsnagSettings
 
-  -- * Exported for testing
+    -- * Exported for testing
   , sqlErrorGroupingHash
 
-  -- * Re-exports
+    -- * Re-exports
   , MonadReader
   , runReaderT
   , module Network.Bugsnag
@@ -29,12 +29,12 @@
 import Data.Bugsnag.Settings
 import qualified Data.ByteString.Char8 as BS8
 import Data.List (isInfixOf)
-import Database.PostgreSQL.Simple (SqlError(..))
+import Database.PostgreSQL.Simple (SqlError (..))
 import Database.PostgreSQL.Simple.Errors
 import qualified Freckle.App.Env as Env
 import Network.Bugsnag hiding (notifyBugsnag, notifyBugsnagWith)
 import qualified Network.Bugsnag as Bugsnag
-import Network.HTTP.Client (HttpException(..), host, method)
+import Network.HTTP.Client (HttpException (..), host, method)
 import qualified UnliftIO.Exception as Exception
 import Yesod.Core.Lens
 import Yesod.Core.Types (HandlerData)
@@ -42,14 +42,16 @@
 class HasAppVersion env where
   appVersionL :: Lens' env Text
 
-instance HasAppVersion site =>  HasAppVersion (HandlerData child site) where
+instance HasAppVersion site => HasAppVersion (HandlerData child site) where
   appVersionL = envL . siteL . appVersionL
 
 setAppVersion :: Text -> BeforeNotify
-setAppVersion version = updateEvent $ \event -> event
-  { event_app = Just $ updateApp $ fromMaybe defaultApp $ event_app event
-  }
-  where updateApp app = app { app_version = Just version }
+setAppVersion version = updateEvent $ \event ->
+  event
+    { event_app = Just $ updateApp $ fromMaybe defaultApp $ event_app event
+    }
+ where
+  updateApp app = app {app_version = Just version}
 
 class HasBugsnagSettings env where
   bugsnagSettingsL :: Lens' env Settings
@@ -57,14 +59,13 @@
 instance HasBugsnagSettings Settings where
   bugsnagSettingsL = id
 
-instance HasBugsnagSettings site =>  HasBugsnagSettings (HandlerData child site) where
+instance HasBugsnagSettings site => HasBugsnagSettings (HandlerData child site) where
   bugsnagSettingsL = envL . siteL . bugsnagSettingsL
 
 -- | Notify Bugsnag of an exception
 --
 -- The notification is made asynchronously via a simple @'forkIO'@. This is
 -- best-effort and we don't care to keep track of the spawned threads.
---
 notifyBugsnag
   :: ( MonadIO m
      , MonadReader env m
@@ -93,18 +94,19 @@
 asSqlError err@SqlError {..} = toSqlGrouping <> toSqlException
  where
   toSqlGrouping = maybe mempty setGroupingHash (sqlErrorGroupingHash err)
-  toSqlException = updateExceptions $ \ex -> ex
-    { exception_errorClass = decodeUtf8 $ "SqlError-" <> sqlState
-    , exception_message =
-      Just
-      $ decodeUtf8
-      $ sqlErrorMsg
-      <> ": "
-      <> sqlErrorDetail
-      <> " ("
-      <> sqlErrorHint
-      <> ")"
-    }
+  toSqlException = updateExceptions $ \ex ->
+    ex
+      { exception_errorClass = decodeUtf8 $ "SqlError-" <> sqlState
+      , exception_message =
+          Just $
+            decodeUtf8 $
+              sqlErrorMsg
+                <> ": "
+                <> sqlErrorDetail
+                <> " ("
+                <> sqlErrorHint
+                <> ")"
+      }
 
 sqlErrorGroupingHash :: SqlError -> Maybe Text
 sqlErrorGroupingHash err = do
@@ -118,27 +120,28 @@
 asHttpException (HttpExceptionRequest req content) =
   setGroupingHash (decodeUtf8 $ host req) <> update
  where
-  update = updateExceptions $ \ex -> ex
-    { exception_errorClass = "HttpExceptionRequest"
-    , exception_message =
-      Just
-      . decodeUtf8
-      $ method req
-      <> " request to "
-      <> host req
-      <> " failed: "
-      <> BS8.pack (show content)
+  update = updateExceptions $ \ex ->
+    ex
+      { exception_errorClass = "HttpExceptionRequest"
+      , exception_message =
+          Just
+            . decodeUtf8
+            $ method req
+              <> " request to "
+              <> host req
+              <> " failed: "
+              <> BS8.pack (show content)
+      }
+asHttpException (InvalidUrlException url msg) = updateExceptions $ \ex ->
+  ex
+    { exception_errorClass = "InvalidUrlException"
+    , exception_message = Just $ pack $ url <> " is invalid: " <> msg
     }
-asHttpException (InvalidUrlException url msg) = updateExceptions $ \ex -> ex
-  { exception_errorClass = "InvalidUrlException"
-  , exception_message = Just $ pack $ url <> " is invalid: " <> msg
-  }
 
 -- | Set StackFrame's InProject to @'False'@ for Error Helper modules
 --
 -- We want exceptions grouped by the the first stack-frame that is /not/ them.
 -- Marking them as not in-project does this, with little downside.
---
 maskErrorHelpers :: BeforeNotify
 maskErrorHelpers = setStackFramesInProjectByFile (`isInfixOf` "Exceptions")
 
@@ -150,10 +153,11 @@
     <$> Env.var Env.nonempty "BUGSNAG_API_KEY" mempty
     <*> Env.var Env.nonempty "BUGSNAG_RELEASE_STAGE" (Env.def "development")
  where
-  build key stage = (defaultSettings key)
-    { settings_releaseStage = stage
-    , settings_beforeNotify = globalBeforeNotify
-    }
+  build key stage =
+    (defaultSettings key)
+      { settings_releaseStage = stage
+      , settings_beforeNotify = globalBeforeNotify
+      }
 
 globalBeforeNotify :: BeforeNotify
 globalBeforeNotify =
diff --git a/library/Freckle/App/Bugsnag/MetaData.hs b/library/Freckle/App/Bugsnag/MetaData.hs
--- a/library/Freckle/App/Bugsnag/MetaData.hs
+++ b/library/Freckle/App/Bugsnag/MetaData.hs
@@ -1,18 +1,15 @@
 -- | Working with Bugsnag's 'event_metaData' field
---
--- $details
---
 module Freckle.App.Bugsnag.MetaData
-  ( MetaData(..)
+  ( MetaData (..)
   , metaData
   , metaDataL
 
-  -- * Collecting ambient data
+    -- * Collecting ambient data
   , collectMetaData
   , collectMetaDataFromStatsClient
   , collectMetaDataFromThreadContext
 
-  -- * 'BeforeNotify'
+    -- * 'BeforeNotify'
   , mergeMetaData
   ) where
 
@@ -21,11 +18,11 @@
 import Blammo.Logging (Pair, myThreadContext)
 import Control.Lens (Lens', lens, to, view, (<>~))
 import Data.Aeson
-import Data.Bugsnag (Event(..))
+import Data.Bugsnag (Event (..))
 import Data.String (fromString)
 import qualified Freckle.App.Aeson as Aeson
 import Freckle.App.Bugsnag
-import Freckle.App.Stats (HasStatsClient(..), tagsL)
+import Freckle.App.Stats (HasStatsClient (..), tagsL)
 
 newtype MetaData = MetaData
   { unMetaData :: Object
@@ -33,11 +30,10 @@
   deriving stock (Eq, Show)
 
 instance Semigroup MetaData where
-  -- | /Right/-biased, recursive union
+  -- \| /Right/-biased, recursive union
   --
   -- The chosen bias ensures that adding metadata in smaller scopes (later)
   -- overrides values from larger scopes.
-  --
   MetaData x <> MetaData y = MetaData $ unionObjects y x
    where
     unionObjects :: Object -> Object -> Object
@@ -62,13 +58,12 @@
 metaDataL = lens get set
  where
   get event = maybe mempty MetaData $ event_metaData event
-  set event md = event { event_metaData = Just $ unMetaData md }
+  set event md = event {event_metaData = Just $ unMetaData md}
 
 -- | Collect 'MetaData' from a 'StatsClient' and 'myThreadContext'
 --
 -- Using this (and then 'mergeMetaData') will unify exception metadata with
 -- metrics tags and the logging context.
---
 collectMetaData
   :: (MonadIO m, MonadReader env m, HasStatsClient env) => m MetaData
 collectMetaData =
@@ -77,7 +72,8 @@
 collectMetaDataFromStatsClient
   :: (MonadReader env m, HasStatsClient env) => m MetaData
 collectMetaDataFromStatsClient = view $ statsClientL . tagsL . to toMetaData
-  where toMetaData = metaData "tags" . map (bimap (fromString . unpack) String)
+ where
+  toMetaData = metaData "tags" . map (bimap (fromString . unpack) String)
 
 collectMetaDataFromThreadContext :: MonadIO m => m MetaData
 collectMetaDataFromThreadContext =
@@ -87,7 +83,6 @@
 --
 -- The given metadata will be combined with what already exists using '(<>)',
 -- preserving the incoming values on collisions.
---
 mergeMetaData :: MetaData -> BeforeNotify
 mergeMetaData md = updateEvent $ metaDataL <>~ md
 
diff --git a/library/Freckle/App/Csv.hs b/library/Freckle/App/Csv.hs
--- a/library/Freckle/App/Csv.hs
+++ b/library/Freckle/App/Csv.hs
@@ -4,21 +4,24 @@
 --
 -- A minor extension of [cassava](https://hackage.haskell.org/package/cassava).
 -- Using `MonadValidate` and `Conduit`.
---
 module Freckle.App.Csv
   ( csvWithValidationSink
   , csvWithParserAndValidationSink
-   -- * Conduit Primitives
+
+    -- * Conduit Primitives
   , runCsvConduit
   , decodeCsv
-  -- * Header Validation
+
+    -- * Header Validation
   , ValidateHeader
   , validateHeader
   , hasHeader
   , defaultValidateOrderedHeader
-  -- * Exceptions
-  , CsvException(..)
-  -- * Options
+
+    -- * Exceptions
+  , CsvException (..)
+
+    -- * Options
   , defaultOptions
   ) where
 
@@ -27,15 +30,21 @@
 import Conduit
 import Control.Monad (foldM)
 import Control.Monad.Validate
-  (MonadValidate(..), Validate, ValidateT, refute, runValidate, runValidateT)
-import Data.Aeson (KeyValue(..), ToJSON(..), object, pairs, (.=))
+  ( MonadValidate (..)
+  , Validate
+  , ValidateT
+  , refute
+  , runValidate
+  , runValidateT
+  )
+import Data.Aeson (KeyValue (..), ToJSON (..), object, pairs, (.=))
 import Data.ByteString (ByteString)
 import qualified Data.ByteString.Char8 as BS8
 import qualified Data.Conduit.Combinators as Conduit
 import qualified Data.Conduit.Text as Conduit
 import Data.Csv
   ( DefaultOrdered
-  , FromNamedRecord(..)
+  , FromNamedRecord (..)
   , Header
   , Name
   , NamedRecord
@@ -48,7 +57,7 @@
 import Data.Functor.Bind (Bind)
 import qualified Data.HashMap.Strict as HashMap
 import qualified Data.List.NonEmpty as NE
-import Data.Proxy (Proxy(Proxy))
+import Data.Proxy (Proxy (Proxy))
 import Data.Sequence (Seq)
 import qualified Data.Sequence as Seq
 import Data.Sequence.NonEmpty (NESeq)
@@ -60,12 +69,12 @@
 -- | Treat CSV header line as 1
 --
 -- CSVs can break rows over lines, but we don't currently handle that.
---
 headerLineNumber :: Int
 headerLineNumber = 1
 
 class ValidateHeader a where
-  validateHeader :: (Bind m, Monad m) => proxy a -> Header -> ValidateT (NESeq String) m ()
+  validateHeader
+    :: (Bind m, Monad m) => proxy a -> Header -> ValidateT (NESeq String) m ()
 
 hasHeader :: Monad m => Header -> Name -> ValidateT (NESeq String) m ()
 hasHeader h name
@@ -85,7 +94,6 @@
 --
 -- - Expects UTF-8
 -- - Provides incremental validation
---
 csvWithValidationSink
   :: forall a b err m
    . ( MonadThrow m
@@ -106,7 +114,6 @@
 --
 -- - Expects UTF-8
 -- - Provides incremental validation
---
 csvWithParserAndValidationSink
   :: forall a b err m
    . (MonadThrow m, MonadUnliftIO m, PrimMonad m)
@@ -120,10 +127,10 @@
   -> m (Validate (NonEmpty (CsvException err)) (Vector b))
 csvWithParserAndValidationSink headerValidator p source validation = do
   validatedCsv <-
-    runCsvConduit
-    $ transPipe lift source
-    .| decodeCsvWithP headerValidator p
-    .| transPipe lift sinkVector
+    runCsvConduit $
+      transPipe lift source
+        .| decodeCsvWithP headerValidator p
+        .| transPipe lift sinkVector
 
   pure $ case validatedCsv of
     Left errs -> refute $ NE.fromList $ toList errs
@@ -153,7 +160,6 @@
 
 -- | Stream in 'ByteString's and parse records in constant space with a custom
 -- record parser
---
 decodeCsvWithP
   :: forall a m err
    . (MonadThrow m, MonadValidate (Seq (CsvException err)) m)
@@ -168,21 +174,22 @@
 
 parseCsv
   :: forall a m err
-   . (MonadValidate (Seq (CsvException err)) m)
+   . MonadValidate (Seq (CsvException err)) m
   => (Header -> Validate (NESeq String) ())
   -> (NamedRecord -> Parser a)
   -> ConduitT ByteString a m ()
-parseCsv headerValidator p = parseHeader
-  headerValidator
-  (CsvI.decodeByNameWithP (stripParser p) defaultDecodeOptions)
+parseCsv headerValidator p =
+  parseHeader
+    headerValidator
+    (CsvI.decodeByNameWithP (stripParser p) defaultDecodeOptions)
 
 data CsvException a
   = CsvMissingColumn !Text
   | CsvParseException !Int !Text
   | CsvFileNotFound
   | CsvUnknownFileEncoding
-  | CsvExceptionExtension a
-  -- ^ A constructor for providing extensible csv exceptions
+  | -- | A constructor for providing extensible csv exceptions
+    CsvExceptionExtension a
   deriving stock (Eq, Show)
 
 instance ToJSON a => ToJSON (CsvException a) where
@@ -192,10 +199,11 @@
 csvExceptionPairs
   :: KeyValue kv => ([kv] -> r) -> (a -> r) -> CsvException a -> r
 csvExceptionPairs done extend = \case
-  CsvMissingColumn column -> done
-    [ "message" .= ("Missing column " <> tshow column)
-    , "missingColumn" .= column
-    ]
+  CsvMissingColumn column ->
+    done
+      [ "message" .= ("Missing column " <> tshow column)
+      , "missingColumn" .= column
+      ]
   CsvParseException rowNumber message ->
     done ["rowNumber" .= rowNumber, "message" .= message]
   CsvFileNotFound -> done ["message" .= ("file not found" :: Text)]
@@ -205,7 +213,7 @@
 
 parseHeader
   :: forall a m err
-   . (MonadValidate (Seq (CsvException err)) m)
+   . MonadValidate (Seq (CsvException err)) m
   => (Header -> Validate (NESeq String) ())
   -> CsvI.HeaderParser (CsvI.Parser a)
   -> ConduitT ByteString a m ()
@@ -216,7 +224,7 @@
     await >>= (parseHeader headerValidator . k) . fromMaybe mempty
   CsvI.DoneH header parser ->
     case runValidate $ headerValidator $ fmap stripUtf8 header of
-      Right{} -> parseRow (succ headerLineNumber) parser
+      Right {} -> parseRow (succ headerLineNumber) parser
       Left errs ->
         lift $ refute $ Seq.fromList $ CsvMissingColumn . pack <$> toList errs
 
@@ -232,7 +240,8 @@
     !newRowNumber <- handleRows rows
     await >>= parseRow newRowNumber . k . fromMaybe mempty
   CsvI.Done rows -> void $ handleRows rows
-  where handleRows = foldM handleRow rowNumber
+ where
+  handleRows = foldM handleRow rowNumber
 
 handleRow
   :: MonadValidate (Seq (CsvException err)) m
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
@@ -4,16 +4,16 @@
 
 -- | Database access for your @App@
 module Freckle.App.Database
-  ( HasSqlPool(..)
+  ( HasSqlPool (..)
   , SqlPool
   , makePostgresPool
   , makePostgresPoolWith
   , runDB
   , runDBSimple
-  , PostgresConnectionConf(..)
-  , PostgresPasswordSource(..)
-  , PostgresPassword(..)
-  , PostgresStatementTimeout(..)
+  , PostgresConnectionConf (..)
+  , PostgresPasswordSource (..)
+  , PostgresPassword (..)
+  , PostgresStatementTimeout (..)
   , postgresStatementTimeoutMilliseconds
   , envParseDatabaseConf
   , envPostgresPasswordSource
@@ -23,7 +23,7 @@
 
 import Blammo.Logging
 import qualified Control.Immortal as Immortal
-import Control.Monad.IO.Unlift (MonadUnliftIO(..))
+import Control.Monad.IO.Unlift (MonadUnliftIO (..))
 import Control.Monad.Reader
 import Data.ByteString (ByteString)
 import qualified Data.ByteString.Char8 as BS8
@@ -41,20 +41,24 @@
   , runSqlPool
   )
 import Database.PostgreSQL.Simple
-  (Connection, Only(..), connectPostgreSQL, execute)
+  ( Connection
+  , Only (..)
+  , connectPostgreSQL
+  , execute
+  )
 import Database.PostgreSQL.Simple.SqlQQ (sql)
 import qualified Freckle.App.Env as Env
-import Freckle.App.OpenTelemetry (MonadTracer(..))
+import Freckle.App.OpenTelemetry (MonadTracer (..))
 import Freckle.App.Stats (HasStatsClient)
 import qualified Freckle.App.Stats as Stats
 import Network.AWS.XRayClient.Persistent
 import Network.AWS.XRayClient.WAI
-import qualified Prelude as Unsafe (read)
 import System.Process.Typed (proc, readProcessStdout_)
 import UnliftIO.Concurrent (threadDelay)
 import UnliftIO.Exception (displayException)
 import UnliftIO.IORef
-import Yesod.Core.Types (HandlerData(..), RunHandlerEnv(..))
+import Yesod.Core.Types (HandlerData (..), RunHandlerEnv (..))
+import qualified Prelude as Unsafe (read)
 
 type SqlPool = Pool SqlBackend
 
@@ -87,8 +91,8 @@
 runDB action = do
   pool <- asks getSqlPool
   mVaultData <- getVaultData
-  Stats.withGauge Stats.dbConnections
-    $ maybe runSqlPool (runSqlPoolXRay "runDB") mVaultData action pool
+  Stats.withGauge Stats.dbConnections $
+    maybe runSqlPool (runSqlPoolXRay "runDB") mVaultData action pool
 
 runDBSimple
   :: (HasSqlPool app, MonadUnliftIO m, MonadReader app m)
@@ -106,21 +110,22 @@
   --
   -- The top-level subsegment will be named @\"<this> runSqlPool\"@ and the,
   -- with a lower-level subsegment named @\"<this> query\"@.
-  --
-  -> XRayVaultData -- ^ Vault data to trace with
+  -> XRayVaultData
+  -- ^ Vault data to trace with
   -> ReaderT backend m a
   -> Pool backend
   -> m a
 runSqlPoolXRay name vaultData action pool =
-  traceXRaySubsegment' vaultData (name <> " runSqlPool") id
-    $ withRunInIO
-    $ \run -> withResource pool $ \backend -> do
+  traceXRaySubsegment' vaultData (name <> " runSqlPool") id $
+    withRunInIO $
+      \run -> withResource pool $ \backend -> do
         let
           sendTrace = atomicallyAddVaultDataSubsegment vaultData
           stdGenIORef = xrayVaultDataStdGen vaultData
           subsegmentName = name <> " query"
-        run . runSqlConn action =<< liftIO
-          (xraySqlBackend sendTrace stdGenIORef subsegmentName backend)
+        run . runSqlConn action
+          =<< liftIO
+            (xraySqlBackend sendTrace stdGenIORef subsegmentName backend)
 
 data PostgresConnectionConf = PostgresConnectionConf
   { pccHost :: String
@@ -170,7 +175,6 @@
 --
 -- >>> readPostgresStatementTimeout "2m0"
 -- Left "..."
---
 readPostgresStatementTimeout
   :: String -> Either String PostgresStatementTimeout
 readPostgresStatementTimeout x = case span isDigit x of
@@ -182,11 +186,12 @@
   _ -> Left "must be {digits}(s|ms)"
 
 envPostgresPasswordSource :: Env.Parser Env.Error PostgresPasswordSource
-envPostgresPasswordSource = Env.flag
-  (Env.Off PostgresPasswordSourceEnv)
-  (Env.On PostgresPasswordSourceIamAuth)
-  "USE_RDS_IAM_AUTH"
-  mempty
+envPostgresPasswordSource =
+  Env.flag
+    (Env.Off PostgresPasswordSourceEnv)
+    (Env.On PostgresPasswordSourceIamAuth)
+    "USE_RDS_IAM_AUTH"
+    mempty
 
 envParseDatabaseConf
   :: PostgresPasswordSource -> Env.Parser Env.Error PostgresConnectionConf
@@ -202,18 +207,19 @@
   poolSize <- Env.var Env.auto "PGPOOLSIZE" $ Env.def 10
   schema <- optional $ Env.var Env.nonempty "PGSCHEMA" mempty
   statementTimeout <-
-    Env.var (Env.eitherReader readPostgresStatementTimeout) "PGSTATEMENTTIMEOUT"
-      $ Env.def (PostgresStatementTimeoutSeconds 120)
-  pure PostgresConnectionConf
-    { pccHost = host
-    , pccPort = port
-    , pccUser = user
-    , pccPassword = password
-    , pccDatabase = database
-    , pccPoolSize = poolSize
-    , pccStatementTimeout = statementTimeout
-    , pccSchema = schema
-    }
+    Env.var (Env.eitherReader readPostgresStatementTimeout) "PGSTATEMENTTIMEOUT" $
+      Env.def (PostgresStatementTimeoutSeconds 120)
+  pure
+    PostgresConnectionConf
+      { pccHost = host
+      , pccPort = port
+      , pccUser = user
+      , pccPassword = password
+      , pccDatabase = database
+      , pccPoolSize = poolSize
+      , pccStatementTimeout = statementTimeout
+      , pccSchema = schema
+      }
 
 data AuroraIamToken = AuroraIamToken
   { aitToken :: Text
@@ -224,27 +230,28 @@
 
 createAuroraIamToken :: MonadIO m => PostgresConnectionConf -> m AuroraIamToken
 createAuroraIamToken aitPostgresConnectionConf@PostgresConnectionConf {..} = do
-  aitToken <- T.strip . decodeUtf8 . BSL.toStrict <$> readProcessStdout_
-    (proc
-      "aws"
-      [ "rds"
-      , "generate-db-auth-token"
-      , "--hostname"
-      , pccHost
-      , "--port"
-      , show pccPort
-      , "--username"
-      , pccUser
-      ]
-    )
+  aitToken <-
+    T.strip . decodeUtf8 . BSL.toStrict
+      <$> readProcessStdout_
+        ( proc
+            "aws"
+            [ "rds"
+            , "generate-db-auth-token"
+            , "--hostname"
+            , pccHost
+            , "--port"
+            , show pccPort
+            , "--username"
+            , pccUser
+            ]
+        )
   aitCreatedAt <- liftIO getCurrentTime
-  pure AuroraIamToken { .. }
+  pure AuroraIamToken {..}
 
 -- | Spawns a thread that refreshes the IAM auth token every minute
 --
 -- The IAM auth token lasts 15 minutes, but we refresh it every minute just to
 -- be super safe.
---
 spawnIamTokenRefreshThread
   :: (MonadUnliftIO m, MonadLogger m)
   => PostgresConnectionConf
@@ -262,9 +269,9 @@
 
   onFinishCallback = \case
     Left ex ->
-      logError
-        $ "Error refreshing IAM auth token"
-        :# ["exception" .= displayException ex]
+      logError $
+        "Error refreshing IAM auth token"
+          :# ["exception" .= displayException ex]
     Right () -> pure ()
 
 refreshIamToken
@@ -277,20 +284,22 @@
 setStartupOptions PostgresConnectionConf {..} conn = do
   let timeoutMillis = postgresStatementTimeoutMilliseconds pccStatementTimeout
   liftIO $ do
-    void $ execute
-      conn
-      [sql| SET statement_timeout = ? |]
-      (Only timeoutMillis)
+    void $
+      execute
+        conn
+        [sql| SET statement_timeout = ? |]
+        (Only timeoutMillis)
     for_ pccSchema $ \schema -> execute conn [sql| SET search_path TO ? |] (Only schema)
 
 makePostgresPoolWith
   :: (MonadUnliftIO m, MonadLoggerIO m) => PostgresConnectionConf -> m SqlPool
 makePostgresPoolWith conf@PostgresConnectionConf {..} = case pccPassword of
   PostgresPasswordIamAuth -> makePostgresPoolWithIamAuth conf
-  PostgresPasswordStatic password -> createPostgresqlPoolModified
-    (setStartupOptions conf)
-    (postgresConnectionString conf password)
-    pccPoolSize
+  PostgresPasswordStatic password ->
+    createPostgresqlPoolModified
+      (setStartupOptions conf)
+      (postgresConnectionString conf password)
+      pccPoolSize
 
 -- | Creates a PostgreSQL pool using IAM auth for the password
 makePostgresPoolWithIamAuth
@@ -308,10 +317,11 @@
 
 postgresConnectionString :: PostgresConnectionConf -> String -> ByteString
 postgresConnectionString PostgresConnectionConf {..} password =
-  BS8.pack $ unwords
-    [ "host=" <> pccHost
-    , "port=" <> show pccPort
-    , "user=" <> pccUser
-    , "password=" <> password
-    , "dbname=" <> pccDatabase
-    ]
+  BS8.pack $
+    unwords
+      [ "host=" <> pccHost
+      , "port=" <> show pccPort
+      , "user=" <> pccUser
+      , "password=" <> password
+      , "dbname=" <> pccDatabase
+      ]
diff --git a/library/Freckle/App/Dotenv.hs b/library/Freckle/App/Dotenv.hs
--- a/library/Freckle/App/Dotenv.hs
+++ b/library/Freckle/App/Dotenv.hs
@@ -35,7 +35,6 @@
 --    have a @.env(.test)@ file (such as this one!).
 --
 -- 3. Use the @.env.example@ feature, but only if one exists alongside
---
 loadFile :: FilePath -> IO ()
 loadFile = traverse_ go <=< locateInParents
  where
@@ -43,10 +42,12 @@
     let examplePath = takeDirectory path </> ".env.example"
     exampleExists <- doesFileExist examplePath
 
-    void $ Dotenv.loadFile $ Dotenv.defaultConfig
-      { Dotenv.configPath = [path]
-      , Dotenv.configExamplePath = [ examplePath | exampleExists ]
-      }
+    void $
+      Dotenv.loadFile $
+        Dotenv.defaultConfig
+          { Dotenv.configPath = [path]
+          , Dotenv.configExamplePath = [examplePath | exampleExists]
+          }
 
 locateInParents :: FilePath -> IO (Maybe FilePath)
 locateInParents path = go =<< getCurrentDirectory
diff --git a/library/Freckle/App/Ecs.hs b/library/Freckle/App/Ecs.hs
--- a/library/Freckle/App/Ecs.hs
+++ b/library/Freckle/App/Ecs.hs
@@ -1,19 +1,19 @@
 module Freckle.App.Ecs
-  ( EcsMetadata(..)
-  , EcsMetadataError(..)
-  , EcsContainerMetadata(..)
-  , EcsContainerTaskMetadata(..)
+  ( EcsMetadata (..)
+  , EcsMetadataError (..)
+  , EcsContainerMetadata (..)
+  , EcsContainerTaskMetadata (..)
   , getEcsMetadata
   ) where
 
 import Freckle.App.Prelude
 
-import Control.Monad.Except (MonadError(..))
+import Control.Monad.Except (MonadError (..))
 import Data.Aeson
 import Data.List.Extra (dropPrefix)
 import Freckle.App.Http
 import System.Environment (lookupEnv)
-import UnliftIO.Exception (Exception(..))
+import UnliftIO.Exception (Exception (..))
 
 data EcsMetadata = EcsMetadata
   { emContainerMetadata :: EcsContainerMetadata
@@ -25,19 +25,18 @@
   | EcsMetadataErrorInvalidURI String
   | EcsMetadataErrorUnexpectedStatus Request Status
   | EcsMetadataErrorInvalidJSON Request HttpDecodeError
-  deriving stock Show
+  deriving stock (Show)
 
 -- | Parsing for the @/@ response
 --
 -- <https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v4.html#task-metadata-endpoint-v4-examples>
---
 data EcsContainerMetadata = EcsContainerMetadata
   { ecmDockerId :: Text
   , ecmDockerName :: Text
   , ecmImage :: Text
   , ecmImageID :: Text
   }
-  deriving stock Generic
+  deriving stock (Generic)
 
 instance FromJSON EcsContainerMetadata where
   parseJSON = genericParseJSON $ aesonDropPrefix "ecm"
@@ -45,26 +44,28 @@
 -- | Parsing of the @/task@ response
 --
 -- <https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v4.html#task-metadata-endpoint-v4-response>
---
 data EcsContainerTaskMetadata = EcsContainerTaskMetadata
   { ectmCluster :: Text
   , ectmTaskARN :: Text
   , ectmFamily :: Text
   , ectmRevision :: Text
   }
-  deriving stock Generic
+  deriving stock (Generic)
 
 instance FromJSON EcsContainerTaskMetadata where
   parseJSON = genericParseJSON $ aesonDropPrefix "ectm"
 
 aesonDropPrefix :: String -> Options
-aesonDropPrefix x = defaultOptions { fieldLabelModifier = dropPrefix x }
+aesonDropPrefix x = defaultOptions {fieldLabelModifier = dropPrefix x}
 
 getEcsMetadata :: (MonadIO m, MonadError EcsMetadataError m) => m EcsMetadata
 getEcsMetadata = do
   mURI <-
-    liftIO $ (<|>) <$> lookupEnv "ECS_CONTAINER_METADATA_URI_V4" <*> lookupEnv
-      "ECS_CONTAINER_METADATA_URI"
+    liftIO $
+      (<|>)
+        <$> lookupEnv "ECS_CONTAINER_METADATA_URI_V4"
+        <*> lookupEnv
+          "ECS_CONTAINER_METADATA_URI"
 
   uri <- maybe (throwError EcsMetadataErrorNotEnabled) pure mURI
 
@@ -75,15 +76,16 @@
 makeContainerMetadataRequest
   :: (MonadIO m, MonadError EcsMetadataError m, FromJSON a) => String -> m a
 makeContainerMetadataRequest uri = do
-  req <- mapEither (EcsMetadataErrorInvalidURI . displayException)
-    $ parseRequest uri
+  req <-
+    mapEither (EcsMetadataErrorInvalidURI . displayException) $
+      parseRequest uri
   resp <- httpJson req
 
   let status = getResponseStatus resp
 
-  unless (statusIsSuccessful status)
-    $ throwError
-    $ EcsMetadataErrorUnexpectedStatus req status
+  unless (statusIsSuccessful status) $
+    throwError $
+      EcsMetadataErrorUnexpectedStatus req status
 
   mapEither (EcsMetadataErrorInvalidJSON req) $ getResponseBody resp
 
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
@@ -19,16 +19,15 @@
 -- >   <$> var auto "BATCH_SIZE" (def 1)
 -- >   <*> switch "DRY_RUN" mempty
 -- >   <*> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty
---
 module Freckle.App.Env
   ( module Env
 
-  -- * Replacements
-  , Off(..)
-  , On(..)
+    -- * Replacements
+  , Off (..)
+  , On (..)
   , flag
 
-  -- * Extensions
+    -- * Extensions
   , kept
   , eitherReader
   , time
@@ -43,7 +42,7 @@
 import Env hiding (flag)
 import qualified Env
 import Env.Internal.Free (hoistAlt)
-import Env.Internal.Parser (Parser(..), VarF(..))
+import Env.Internal.Parser (Parser (..), VarF (..))
 
 -- | Designates the value of a parameter when a flag is not provided.
 newtype Off a = Off a
@@ -74,7 +73,6 @@
 --
 -- >>> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty `parsePure` [("DEBUG", "no")]
 -- Right LevelDebug
---
 flag :: Off a -> On a -> String -> Mod Flag a -> Parser Error a
 flag (Off f) (On t) n m = Env.flag f t n m
 
@@ -88,7 +86,6 @@
 -- In @envparse-0.5@, the default is reversed and @sensitive@ can be used to
 -- explicitly unset read variables, and so this function will instead make them
 -- all behave as if @sensitive@ was /not/ used.
---
 kept :: Parser e a -> Parser e a
 kept = Parser . hoistAlt go . unParser
  where
@@ -102,10 +99,10 @@
 -- | Create a 'Reader' from a simple parser function
 --
 -- This is a building-block for other 'Reader's
---
 eitherReader :: (String -> Either String a) -> Reader Error a
 eitherReader f s = first (unread . suffix) $ f s
-  where suffix x = x <> ": " <> show s
+ where
+  suffix x = x <> ": " <> show s
 
 -- | Read a time value using the given format
 --
@@ -114,12 +111,11 @@
 --
 -- >>> var (time "%Y-%m-%d") "TIME" mempty `parsePure` [("TIME", "10:00PM")]
 -- Left [("TIME",UnreadError "unable to parse time as %Y-%m-%d: \"10:00PM\"")]
---
 time :: String -> Reader Error UTCTime
 time fmt =
-  eitherReader
-    $ note ("unable to parse time as " <> fmt)
-    . parseTimeM True defaultTimeLocale fmt
+  eitherReader $
+    note ("unable to parse time as " <> fmt)
+      . parseTimeM True defaultTimeLocale fmt
 
 -- | Read key-value pairs
 --
@@ -135,7 +131,6 @@
 --
 -- >>> var keyValues "TAGS" mempty `parsePure` [("TAGS", "foo:bar,:bat")]
 -- Left [("TAGS",UnreadError "Value bat has no key: \"foo:bar,:bat\"")]
---
 keyValues :: Reader Error [(Text, Text)]
 keyValues = eitherReader $ traverse keyValue . T.splitOn "," . pack
  where
diff --git a/library/Freckle/App/GlobalCache.hs b/library/Freckle/App/GlobalCache.hs
--- a/library/Freckle/App/GlobalCache.hs
+++ b/library/Freckle/App/GlobalCache.hs
@@ -42,7 +42,6 @@
 --     LogStderr -> newStderrLoggerSet
 --     LogFile f -> flip newFileLoggerSet f
 -- @
---
 module Freckle.App.GlobalCache
   ( GlobalCache
   , newGlobalCache
@@ -66,7 +65,8 @@
 globallyCache (GlobalCache var) construct = do
   mv <- readIORef var
   maybe (cache =<< construct) pure mv
-  where cache v = atomicModifyIORef' var $ const (Just v, v)
+ where
+  cache v = atomicModifyIORef' var $ const (Just v, v)
 
 -- | Garbage collect one of our 'IORef's after an action has run
 --
@@ -77,7 +77,6 @@
 -- To avoid garbage collection issues, we can leverage a "System.Mem.Weak" to
 -- add a finalizer. When that 'MVar' gets garbage collected we can clear the
 -- global 'IORef'. This maintains the status quo, with minimal plumbing.
---
 withGlobalCacheCleanup :: GlobalCache a -> IO b -> IO ()
 withGlobalCacheCleanup (GlobalCache var) action = do
   cleanup <- newMVar ()
diff --git a/library/Freckle/App/Http.hs b/library/Freckle/App/Http.hs
--- a/library/Freckle/App/Http.hs
+++ b/library/Freckle/App/Http.hs
@@ -50,22 +50,21 @@
 --
 -- See "Freckle.Http.App.Paginate" to process requested pages in a streaming
 -- fashion, or perform pagination based on somethign other than @Link@.
---
 module Freckle.App.Http
   ( httpJson
-  , HttpDecodeError(..)
+  , HttpDecodeError (..)
   , httpDecode
   , httpLbs
   , httpNoBody
   , httpPaginated
   , sourcePaginated
 
-  -- * Request builders
+    -- * Request builders
   , Request
   , parseRequest
   , parseRequest_
 
-  -- * Request modifiers
+    -- * Request modifiers
   , addRequestHeader
   , addAcceptHeader
   , addBearerAuthorizationHeader
@@ -76,38 +75,35 @@
   , setRequestCheckStatus
   , setRequestPath
 
-  -- * Response accessors
+    -- * Response accessors
   , Response
   , getResponseStatus
   , getResponseBody
 
-  -- ** Unsafe access
+    -- ** Unsafe access
   , getResponseBodyUnsafe
 
-  -- * Exceptions
-  , HttpException(..)
-
-  -- **
-  -- | Predicates useful for handling 'HttpException's
-  --
-  -- For example, given a function 'guarded', which returns 'Just' a given value
-  -- when a predicate holds for it (otherwise 'Nothing'), you can add
-  -- error-handling specific to exceptions caused by 4XX responses:
-  --
-  -- @
-  -- 'handleJust' (guarded 'httpExceptionIsClientError') handle4XXError $ do
-  --   resp <- 'httpJson' $ 'setRequestCheckStatus' $ parseRequest_ "http://..."
-  --   body <- 'getResponseBodyUnsafe' resp
-  --
-  --   -- ...
-  -- @
-  --
+    -- * Exceptions
+  , HttpException (..)
+    -- | Predicates useful for handling 'HttpException's
+    --
+    -- For example, given a function 'guarded', which returns 'Just' a given value
+    -- when a predicate holds for it (otherwise 'Nothing'), you can add
+    -- error-handling specific to exceptions caused by 4XX responses:
+    --
+    -- @
+    -- 'handleJust' (guarded 'httpExceptionIsClientError') handle4XXError $ do
+    --   resp <- 'httpJson' $ 'setRequestCheckStatus' $ parseRequest_ "http://..."
+    --   body <- 'getResponseBodyUnsafe' resp
+    --
+    --   -- ...
+    -- @
   , httpExceptionIsInformational
   , httpExceptionIsRedirection
   , httpExceptionIsClientError
   , httpExceptionIsServerError
 
-  -- * "Network.HTTP.Types" re-exports
+    -- * "Network.HTTP.Types" re-exports
   , Status
   , statusCode
   , statusIsInformational
@@ -128,7 +124,7 @@
 import qualified Data.List.NonEmpty as NE
 import Freckle.App.Http.Paginate
 import Freckle.App.Http.Retry
-import Network.HTTP.Conduit (HttpExceptionContent(..))
+import Network.HTTP.Conduit (HttpExceptionContent (..))
 import Network.HTTP.Simple hiding (httpLbs, httpNoBody)
 import qualified Network.HTTP.Simple as HTTP
 import Network.HTTP.Types.Header (hAccept, hAuthorization)
@@ -141,7 +137,7 @@
   , statusIsServerError
   , statusIsSuccessful
   )
-import UnliftIO.Exception (Exception(..), throwIO)
+import UnliftIO.Exception (Exception (..), throwIO)
 
 data HttpDecodeError = HttpDecodeError
   { hdeBody :: ByteString
@@ -151,9 +147,9 @@
 
 instance Exception HttpDecodeError where
   displayException HttpDecodeError {..} =
-    unlines
-      $ ["Error decoding HTTP Response:", "Raw body:", BSL8.unpack hdeBody]
-      <> fromErrors hdeErrors
+    unlines $
+      ["Error decoding HTTP Response:", "Raw body:", BSL8.unpack hdeBody]
+        <> fromErrors hdeErrors
    where
     fromErrors = \case
       err NE.:| [] -> ["Error:", err]
@@ -165,8 +161,9 @@
   :: (MonadIO m, FromJSON a)
   => Request
   -> m (Response (Either HttpDecodeError a))
-httpJson = httpDecode (first pure . Aeson.eitherDecode)
-  . addAcceptHeader "application/json"
+httpJson =
+  httpDecode (first pure . Aeson.eitherDecode)
+    . addAcceptHeader "application/json"
 
 -- | Request and decode a response
 httpDecode
@@ -195,7 +192,6 @@
 -- The second argument is used to extract the data to combine out of the
 -- response. This is particularly useful for 'Either' values, like you may get
 -- from 'httpJson'. It lives in @m@ to support functions such as 'getResponseBodyUnsafe'.
---
 httpPaginated
   :: (MonadIO m, Monoid b)
   => (Request -> m (Response a))
@@ -216,7 +212,6 @@
 -- If you plan to use this function, and haven't built your decoding to handle
 -- error response bodies too, you'll want to use 'setRequestCheckStatus' so that
 -- you see status-code exceptions before 'HttpDecodeError's.
---
 getResponseBodyUnsafe
   :: (MonadIO m, Exception e) => Response (Either e a) -> m a
 getResponseBodyUnsafe = either throwIO pure . getResponseBody
diff --git a/library/Freckle/App/Http/Paginate.hs b/library/Freckle/App/Http/Paginate.hs
--- a/library/Freckle/App/Http/Paginate.hs
+++ b/library/Freckle/App/Http/Paginate.hs
@@ -51,7 +51,6 @@
 --   let next = C8.pack $ show $ pNext body
 --   pure $ addToRequestQueryString [("next", Just next)] req
 -- @
---
 module Freckle.App.Http.Paginate
   ( sourcePaginated
   , sourcePaginatedBy
@@ -65,7 +64,6 @@
 import Network.HTTP.Simple
 
 -- | Stream pages of a paginated response, using @Link@ to find next pages
---
 sourcePaginated
   :: MonadIO m
   => (Request -> m (Response body))
diff --git a/library/Freckle/App/Http/Retry.hs b/library/Freckle/App/Http/Retry.hs
--- a/library/Freckle/App/Http/Retry.hs
+++ b/library/Freckle/App/Http/Retry.hs
@@ -1,5 +1,5 @@
 module Freckle.App.Http.Retry
-  ( RetriesExhausted(..)
+  ( RetriesExhausted (..)
   , rateLimited
   , rateLimited'
   ) where
@@ -8,11 +8,11 @@
 
 import Control.Retry
 import qualified Data.ByteString.Char8 as BS8
-import Network.HTTP.Client (Request(..))
+import Network.HTTP.Client (Request (..))
 import Network.HTTP.Simple
 import Network.HTTP.Types.Status (status429)
 import Text.Read (readMaybe)
-import UnliftIO.Exception (Exception(..), throwIO)
+import UnliftIO.Exception (Exception (..), throwIO)
 
 -- | Thrown if we exhaust our retries limit and still see a @429@
 --
@@ -31,12 +31,11 @@
 -- Unfortunately, it's not possible to reuse the user-defined 'checkResponse' in
 -- order to throw a uniform 'HttpException' in this case; so we throw this
 -- ourselves instead.
---
 data RetriesExhausted = RetriesExhausted
   { reLimit :: Int
   , reResponse :: Response ()
   }
-  deriving stock Show
+  deriving stock (Show)
 
 instance Exception RetriesExhausted where
   displayException RetriesExhausted {..} =
@@ -57,29 +56,33 @@
   -> Request
   -> m (Response body)
 rateLimited' retryLimit f req = do
-  resp <- retryingDynamic
-    (limitRetries retryLimit)
-    (\_ ->
-      pure
-        . maybe DontRetry (ConsultPolicyOverrideDelay . microseconds)
-        . getRetryAfter
-    )
-    (\_ -> f $ suppressRetryStatusError req)
+  resp <-
+    retryingDynamic
+      (limitRetries retryLimit)
+      ( \_ ->
+          pure
+            . maybe DontRetry (ConsultPolicyOverrideDelay . microseconds)
+            . getRetryAfter
+      )
+      (\_ -> f $ suppressRetryStatusError req)
 
   checkRetriesExhausted retryLimit resp
 
 suppressRetryStatusError :: Request -> Request
-suppressRetryStatusError req = req
-  { checkResponse = \req' resp ->
-    unless (getResponseStatus resp == status429)
-      $ originalCheckResponse req' resp
-  }
-  where originalCheckResponse = checkResponse req
+suppressRetryStatusError req =
+  req
+    { checkResponse = \req' resp ->
+        unless (getResponseStatus resp == status429) $
+          originalCheckResponse req' resp
+    }
+ where
+  originalCheckResponse = checkResponse req
 
 checkRetriesExhausted :: MonadIO m => Int -> Response body -> m (Response body)
 checkRetriesExhausted retryLimit resp
-  | getResponseStatus resp == status429 = throwIO
-  $ RetriesExhausted { reLimit = retryLimit, reResponse = void resp }
+  | getResponseStatus resp == status429 =
+      throwIO $
+        RetriesExhausted {reLimit = retryLimit, reResponse = void resp}
   | otherwise = pure resp
 
 getRetryAfter :: Response body -> Maybe Int
diff --git a/library/Freckle/App/Kafka.hs b/library/Freckle/App/Kafka.hs
new file mode 100644
--- /dev/null
+++ b/library/Freckle/App/Kafka.hs
@@ -0,0 +1,129 @@
+{-# LANGUAGE NamedFieldPuns #-}
+
+module Freckle.App.Kafka
+  ( envKafkaBrokerAddresses
+  , KafkaProducerPool (..)
+  , HasKafkaProducerPool (..)
+  , createKafkaProducerPool
+  , produceKeyedOn
+  , produceKeyedOnAsync
+  ) where
+
+import Freckle.App.Prelude
+
+import Blammo.Logging
+import Control.Lens (Lens', view)
+import Data.Aeson (ToJSON, encode)
+import Data.ByteString.Lazy (toStrict)
+import qualified Data.List.NonEmpty as NE
+import Data.Pool (Pool)
+import qualified Data.Pool as Pool
+import qualified Data.Text as T
+import qualified Freckle.App.Env as Env
+import Kafka.Producer
+import UnliftIO.Async (async)
+import UnliftIO.Exception (throwString)
+import Yesod.Core.Lens
+import Yesod.Core.Types (HandlerData)
+
+envKafkaBrokerAddresses
+  :: Env.Parser Env.Error (NonEmpty BrokerAddress)
+envKafkaBrokerAddresses =
+  Env.var
+    (Env.eitherReader readKafkaBrokerAddresses)
+    "KAFKA_BROKER_ADDRESSES"
+    mempty
+
+readKafkaBrokerAddresses :: String -> Either String (NonEmpty BrokerAddress)
+readKafkaBrokerAddresses t = case NE.nonEmpty $ T.splitOn "," $ T.pack t of
+  Just xs@(x NE.:| _)
+    | x /= "" -> Right $ BrokerAddress <$> xs
+  _ -> Left "Broker Address cannot be empty"
+
+data KafkaProducerPool
+  = NullKafkaProducerPool
+  | KafkaProducerPool (Pool KafkaProducer)
+
+class HasKafkaProducerPool env where
+  kafkaProducerPoolL :: Lens' env KafkaProducerPool
+
+instance HasKafkaProducerPool site => HasKafkaProducerPool (HandlerData child site) where
+  kafkaProducerPoolL = envL . siteL . kafkaProducerPoolL
+
+createKafkaProducerPool
+  :: NonEmpty BrokerAddress
+  -> Int
+  -- ^ The number of stripes (distinct sub-pools) to maintain.
+  -- The smallest acceptable value is 1.
+  -> NominalDiffTime
+  -- ^ Amount of time for which an unused resource is kept open.
+  -- The smallest acceptable value is 0.5 seconds.
+  --
+  -- The elapsed time before destroying a resource may be a little
+  -- longer than requested, as the reaper thread wakes at 1-second
+  -- intervals.
+  -> Int
+  -- ^ Maximum number of resources to keep open per stripe.  The
+  -- smallest acceptable value is 1.
+  --
+  -- Requests for resources will block if this limit is reached on a
+  -- single stripe, even if other stripes have idle resources
+  -- available.
+  -> IO (Pool KafkaProducer)
+createKafkaProducerPool addresses = Pool.createPool mkProducer closeProducer
+ where
+  mkProducer =
+    either throw pure =<< newProducer (brokersList $ toList addresses)
+  throw err = throwString $ "Failed to open kafka producer: " <> show err
+
+produceKeyedOn
+  :: ( ToJSON value
+     , ToJSON key
+     , MonadLogger m
+     , MonadReader env m
+     , HasKafkaProducerPool env
+     , MonadUnliftIO m
+     )
+  => TopicName
+  -> NonEmpty value
+  -> (value -> key)
+  -> m ()
+produceKeyedOn prTopic values keyF = do
+  logDebugNS "kafka" $ "Producing Kafka events" :# ["events" .= values]
+  view kafkaProducerPoolL >>= \case
+    NullKafkaProducerPool -> pure ()
+    KafkaProducerPool producerPool -> do
+      errors <-
+        liftIO $
+          Pool.withResource producerPool $ \producer ->
+            produceMessageBatch producer $
+              toList $
+                mkProducerRecord <$> values
+      unless (null errors) $
+        logErrorNS "kafka" $
+          "Failed to send events" :# ["errors" .= fmap (tshow . snd) errors]
+ where
+  mkProducerRecord value =
+    ProducerRecord
+      { prTopic
+      , prPartition = UnassignedPartition
+      , prKey = Just $ toStrict $ encode $ keyF value
+      , prValue =
+          Just $
+            toStrict $
+              encode value
+      }
+
+produceKeyedOnAsync
+  :: ( ToJSON value
+     , ToJSON key
+     , MonadLogger m
+     , MonadReader env m
+     , HasKafkaProducerPool env
+     , MonadUnliftIO m
+     )
+  => TopicName
+  -> NonEmpty value
+  -> (value -> key)
+  -> m ()
+produceKeyedOnAsync prTopic values = void . async . produceKeyedOn prTopic values
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
@@ -8,14 +8,13 @@
 -- 4. Use 'caching'
 --
 -- To avoid 'Cachable', see 'cachingAs' and 'cachingAsJSON'.
---
 module Freckle.App.Memcached
-  ( Cachable(..)
+  ( Cachable (..)
   , caching
   , cachingAs
   , cachingAsJSON
 
-  -- * Re-exports
+    -- * Re-exports
   , module Freckle.App.Memcached.Client
   , module Freckle.App.Memcached.CacheKey
   , module Freckle.App.Memcached.CacheTTL
@@ -31,9 +30,9 @@
 import Data.Text.Encoding.Error (lenientDecode)
 import Freckle.App.Memcached.CacheKey
 import Freckle.App.Memcached.CacheTTL
-import Freckle.App.Memcached.Client (HasMemcachedClient(..))
+import Freckle.App.Memcached.Client (HasMemcachedClient (..))
 import qualified Freckle.App.Memcached.Client as Memcached
-import UnliftIO.Exception (Exception(..), handleAny)
+import UnliftIO.Exception (Exception (..), handleAny)
 
 class Cachable a where
   toCachable :: a -> ByteString
@@ -81,9 +80,9 @@
   -> m a
 cachingAs from to key ttl f = do
   result <-
-    fmap (maybe CacheNotFound (either (CacheError . pack) CacheFound . from))
-    $ handleCachingError Nothing "getting"
-    $ Memcached.get key
+    fmap (maybe CacheNotFound (either (CacheError . pack) CacheFound . from)) $
+      handleCachingError Nothing "getting" $
+        Memcached.get key
 
   case result of
     CacheFound a -> pure a
@@ -119,10 +118,10 @@
 
 logCachingError :: MonadLogger m => Text -> Text -> m ()
 logCachingError action message =
-  logErrorNS "caching"
-    $ "Error "
-    <> action
-    :# ["action" .= action, "message" .= message]
+  logErrorNS "caching" $
+    "Error "
+      <> action
+      :# ["action" .= action, "message" .= message]
 
 encodeStrict :: ToJSON a => a -> ByteString
 encodeStrict = BSL.toStrict . encode
diff --git a/library/Freckle/App/Memcached/CacheKey.hs b/library/Freckle/App/Memcached/CacheKey.hs
--- a/library/Freckle/App/Memcached/CacheKey.hs
+++ b/library/Freckle/App/Memcached/CacheKey.hs
@@ -13,7 +13,7 @@
 import UnliftIO.Exception (throwString)
 
 newtype CacheKey = CacheKey Text
-  deriving stock Show
+  deriving stock (Show)
   deriving newtype (Eq, Hashable)
 
 unCacheKey :: CacheKey -> Text
@@ -28,7 +28,6 @@
 -- normally clients wouldn't need to use such long keys); the key must not
 -- include control characters or whitespace.
 -- @
---
 cacheKey :: Text -> Either String CacheKey
 cacheKey t
   | T.length t > 250 = invalid "Must be fewer than 250 characters"
diff --git a/library/Freckle/App/Memcached/CacheTTL.hs b/library/Freckle/App/Memcached/CacheTTL.hs
--- a/library/Freckle/App/Memcached/CacheTTL.hs
+++ b/library/Freckle/App/Memcached/CacheTTL.hs
@@ -10,7 +10,7 @@
 import Database.Memcache.Types (Expiration)
 
 newtype CacheTTL = CacheTTL Int
-  deriving stock Show
+  deriving stock (Show)
   deriving newtype (Eq, Ord, Enum, Num, Real, Integral)
 
 cacheTTL :: Int -> CacheTTL
diff --git a/library/Freckle/App/Memcached/Client.hs b/library/Freckle/App/Memcached/Client.hs
--- a/library/Freckle/App/Memcached/Client.hs
+++ b/library/Freckle/App/Memcached/Client.hs
@@ -3,14 +3,14 @@
   , newMemcachedClient
   , withMemcachedClient
   , memcachedClientDisabled
-  , HasMemcachedClient(..)
+  , HasMemcachedClient (..)
   , get
   , set
   ) where
 
 import Freckle.App.Prelude
 
-import Control.Lens (Lens', _1, view)
+import Control.Lens (Lens', view, _1)
 import qualified Database.Memcache.Client as Memcache
 import Database.Memcache.Types (Value)
 import Freckle.App.Memcached.CacheKey
@@ -58,7 +58,6 @@
 -- | Set a value to expire in the given seconds
 --
 -- Pass @0@ to set a value that never expires.
---
 set
   :: (MonadIO m, MonadReader env m, HasMemcachedClient env)
   => CacheKey
@@ -67,8 +66,11 @@
   -> m ()
 set k v expiration = with $ \case
   MemcachedClient mc ->
-    void $ liftIO $ Memcache.set mc (fromCacheKey k) v 0 $ fromCacheTTL
-      expiration
+    void $
+      liftIO $
+        Memcache.set mc (fromCacheKey k) v 0 $
+          fromCacheTTL
+            expiration
   MemcachedClientDisabled -> pure ()
 
 quitClient :: MonadIO m => MemcachedClient -> m ()
diff --git a/library/Freckle/App/Memcached/Servers.hs b/library/Freckle/App/Memcached/Servers.hs
--- a/library/Freckle/App/Memcached/Servers.hs
+++ b/library/Freckle/App/Memcached/Servers.hs
@@ -20,9 +20,8 @@
 -- -- Default to disabled
 -- Env.var (Env.eitherReader readMemcachedServers) "MEMCACHED_SERVERS" (Env.def emptyMemcachedServers)
 -- @
---
 module Freckle.App.Memcached.Servers
-  ( MemcachedServers(..)
+  ( MemcachedServers (..)
   , defaultMemcachedServers
   , emptyMemcachedServers
   , readMemcachedServers
@@ -34,7 +33,7 @@
 import Control.Error.Util (note)
 import qualified Data.Text as T
 import qualified Database.Memcache.Client as Memcache
-import Network.URI (URI(..), URIAuth(..), parseAbsoluteURI)
+import Network.URI (URI (..), URIAuth (..), parseAbsoluteURI)
 
 newtype MemcachedServers = MemcachedServers
   { unMemcachedServers :: [MemcachedServer]
@@ -88,15 +87,16 @@
     guard $ not $ T.null u
     guard $ not $ T.null p
 
-    pure Memcache.Auth
-      { Memcache.username = encodeUtf8 u
-      , Memcache.password = encodeUtf8 p
-      }
+    pure
+      Memcache.Auth
+        { Memcache.username = encodeUtf8 u
+        , Memcache.password = encodeUtf8 p
+        }
 
 setHost :: URIAuth -> Memcache.ServerSpec -> Memcache.ServerSpec
 setHost auth ss = case uriRegName auth of
   "" -> ss
-  rn -> ss { Memcache.ssHost = rn }
+  rn -> ss {Memcache.ssHost = rn}
 
 setPort :: URIAuth -> Memcache.ServerSpec -> Memcache.ServerSpec
 setPort auth ss = fromMaybe ss $ do
@@ -104,7 +104,7 @@
     "" -> Nothing
     (':' : p) -> fromPort p
     p -> fromPort p
-  pure $ ss { Memcache.ssPort = p }
+  pure $ ss {Memcache.ssPort = p}
  where
 #if MIN_VERSION_memcache(0,3,0)
   -- ssPort is a ServiceName, which is a String
@@ -116,4 +116,4 @@
 
 setAuth
   :: Memcache.Authentication -> Memcache.ServerSpec -> Memcache.ServerSpec
-setAuth auth ss = ss { Memcache.ssAuth = auth }
+setAuth auth ss = ss {Memcache.ssAuth = auth}
diff --git a/library/Freckle/App/OpenTelemetry.hs b/library/Freckle/App/OpenTelemetry.hs
--- a/library/Freckle/App/OpenTelemetry.hs
+++ b/library/Freckle/App/OpenTelemetry.hs
@@ -1,5 +1,5 @@
 module Freckle.App.OpenTelemetry
-  ( MonadTracer(..)
+  ( MonadTracer (..)
   ) where
 
 import Freckle.App.Prelude
@@ -11,7 +11,6 @@
 --
 -- This is named the same as the OpenTelemetry class we'll use once we move to
 -- that tracing system
---
 class MonadTracer m where
   getVaultData :: m (Maybe XRayVaultData)
 
diff --git a/library/Freckle/App/Prelude.hs b/library/Freckle/App/Prelude.hs
--- a/library/Freckle/App/Prelude.hs
+++ b/library/Freckle/App/Prelude.hs
@@ -2,7 +2,7 @@
 module Freckle.App.Prelude
   ( module Prelude
 
-  -- * Commonly imported types
+    -- * Commonly imported types
   , Alternative
   , Exception
   , Generic
@@ -24,20 +24,20 @@
   , UTCTime
   , Vector
 
-  -- * Commonly used functions
+    -- * Commonly used functions
 
-  -- ** Lifts
+    -- ** Lifts
   , lift
   , liftIO
 
-  -- ** 'Text'
+    -- ** 'Text'
   , tshow
   , pack
   , unpack
   , encodeUtf8
   , decodeUtf8
 
-  -- ** 'Maybe'
+    -- ** 'Maybe'
   , catMaybes
   , fromMaybe
   , isJust
@@ -46,32 +46,32 @@
   , mapMaybe
   , maybeToList
 
-  -- ** Safe alternatives to partial prelude functions (e.g. 'headMay')
+    -- ** Safe alternatives to partial prelude functions (e.g. 'headMay')
   , module SafeAlternatives
 
-  -- ** 'Either'
+    -- ** 'Either'
   , partitionEithers
 
-  -- ** 'Foldable'
+    -- ** 'Foldable'
   , module Foldable
 
-  -- ** 'Traversable'
+    -- ** 'Traversable'
   , module Traversable
 
-  -- ** 'Functor'
+    -- ** 'Functor'
   , (<$$>)
 
-  -- ** 'Bifunctor'
+    -- ** 'Bifunctor'
   , bimap
   , first
   , second
 
-  -- ** 'Applicative'
+    -- ** 'Applicative'
   , (<|>)
   , liftA2
   , optional
 
-  -- ** 'Monad'
+    -- ** 'Monad'
   , (<=<)
   , (>=>)
   , guard
@@ -80,18 +80,29 @@
   , void
   , when
 
-  -- ** 'Arrow'
+    -- ** 'Arrow'
   , (&&&)
   , (***)
 
-  -- ** 'UTCTime'
+    -- ** 'UTCTime'
   , getCurrentTime
   ) where
 
 -- Use 'Prelude' as the starting point, removing common partial functions
 
 import Prelude hiding
-  (cycle, foldl1, foldr1, head, init, last, maximum, minimum, read, tail, (!!))
+  ( cycle
+  , foldl1
+  , foldr1
+  , head
+  , init
+  , last
+  , maximum
+  , minimum
+  , read
+  , tail
+  , (!!)
+  )
 
 -- Commonly used types (and their commonly used functions)
 
@@ -99,9 +110,9 @@
 import Control.Monad.IO.Class (MonadIO, liftIO)
 import Control.Monad.Primitive (PrimMonad)
 import Control.Monad.Reader (MonadReader, ReaderT)
-import Data.Hashable (Hashable)
 import Data.HashMap.Strict (HashMap)
 import Data.HashSet (HashSet)
+import Data.Hashable (Hashable)
 import Data.Int (Int64)
 import Data.List.NonEmpty (NonEmpty)
 import Data.Map.Strict (Map)
@@ -138,7 +149,14 @@
 import Data.Either (partitionEithers)
 import Data.Foldable as Foldable hiding (foldl1, foldr1)
 import Data.Maybe
-  (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList)
+  ( catMaybes
+  , fromMaybe
+  , isJust
+  , isNothing
+  , listToMaybe
+  , mapMaybe
+  , maybeToList
+  )
 import Data.Text.Encoding (decodeUtf8, encodeUtf8)
 import Data.Traversable as Traversable
 
diff --git a/library/Freckle/App/Scientist.hs b/library/Freckle/App/Scientist.hs
--- a/library/Freckle/App/Scientist.hs
+++ b/library/Freckle/App/Scientist.hs
@@ -13,7 +13,6 @@
 --
 -- * Experiments are labeled "science.${name}"
 -- * Results are tagged with "variant:${name}"
---
 experimentPublishDatadog
   :: (MonadReader env m, MonadUnliftIO m, HasStatsClient env)
   => Result c a b
@@ -23,11 +22,11 @@
     statName = "science." <> resultDetailsExperimentName details
     ResultControl {..} = resultDetailsControl details
 
-  Stats.tagged [("variant", resultControlName)]
-    $ Stats.gauge statName
-    $ durationToSeconds resultControlDuration
+  Stats.tagged [("variant", resultControlName)] $
+    Stats.gauge statName $
+      durationToSeconds resultControlDuration
 
   for_ (resultDetailsCandidates details) $ \ResultCandidate {..} ->
-    Stats.tagged [("variant", "candidate-" <> resultCandidateName)]
-      $ Stats.gauge statName
-      $ durationToSeconds resultCandidateDuration
+    Stats.tagged [("variant", "candidate-" <> resultCandidateName)] $
+      Stats.gauge statName $
+        durationToSeconds resultCandidateDuration
diff --git a/library/Freckle/App/Stats.hs b/library/Freckle/App/Stats.hs
--- a/library/Freckle/App/Stats.hs
+++ b/library/Freckle/App/Stats.hs
@@ -1,28 +1,25 @@
 {-# LANGUAGE TupleSections #-}
 
 -- | An intentionally-leaky StatsD interface to Datadog
---
--- $usage
---
 module Freckle.App.Stats
   ( StatsSettings
   , defaultStatsSettings
   , setStatsSettingsTags
   , envParseStatsSettings
 
-  -- * Client
+    -- * Client
   , StatsClient
   , tagsL
   , withStatsClient
-  , HasStatsClient(..)
+  , HasStatsClient (..)
 
-  -- * Gauges
+    -- * Gauges
   , Gauges
   , Gauge
   , dbConnections
   , withGauge
 
-  -- * Reporting
+    -- * Reporting
   , tagged
   , increment
   , counter
@@ -38,7 +35,7 @@
 import Control.Lens (Lens', lens, to, view, (&), (.~), (<>~))
 import Control.Monad.Except (runExceptT)
 import Control.Monad.Reader (local)
-import Data.Aeson (Value(..))
+import Data.Aeson (Value (..))
 import Data.String
 import Data.Time (diffUTCTime)
 import Freckle.App.Ecs
@@ -57,42 +54,43 @@
   }
 
 defaultStatsSettings :: StatsSettings
-defaultStatsSettings = StatsSettings
-  { amsEnabled = False
-  , amsSettings = Datadog.defaultSettings
-  , amsTags = []
-  }
+defaultStatsSettings =
+  StatsSettings
+    { amsEnabled = False
+    , amsSettings = Datadog.defaultSettings
+    , amsTags = []
+    }
 
 setStatsSettingsTags :: [(Text, Text)] -> StatsSettings -> StatsSettings
-setStatsSettingsTags x settings = settings { amsTags = x }
+setStatsSettingsTags x settings = settings {amsTags = x}
 
 envParseStatsSettings :: Env.Parser Env.Error StatsSettings
 envParseStatsSettings =
   StatsSettings
     <$> Env.switch "DOGSTATSD_ENABLED" mempty
-    <*> (buildSettings
-        <$> optional (Env.var Env.str "DOGSTATSD_HOST" mempty)
-        <*> optional (Env.var Env.auto "DOGSTATSD_PORT" mempty)
+    <*> ( buildSettings
+            <$> optional (Env.var Env.str "DOGSTATSD_HOST" mempty)
+            <*> optional (Env.var Env.auto "DOGSTATSD_PORT" mempty)
         )
-    <*> (buildTags
-        <$> optional (Env.var Env.nonempty "DD_ENV" mempty)
-        <*> optional (Env.var Env.nonempty "DD_SERVICE" mempty)
-        <*> optional (Env.var Env.nonempty "DD_VERSION" mempty)
-        <*> Env.var Env.keyValues "DOGSTATSD_TAGS" (Env.def [])
+    <*> ( buildTags
+            <$> optional (Env.var Env.nonempty "DD_ENV" mempty)
+            <*> optional (Env.var Env.nonempty "DD_SERVICE" mempty)
+            <*> optional (Env.var Env.nonempty "DD_VERSION" mempty)
+            <*> Env.var Env.keyValues "DOGSTATSD_TAGS" (Env.def [])
         )
  where
   buildSettings mHost mPort =
     Datadog.defaultSettings
       & maybe id (Datadog.host .~) mHost
-      . maybe id (Datadog.port .~) mPort
+        . maybe id (Datadog.port .~) mPort
 
   buildTags mEnv mService mVersion tags =
     catMaybes
-        [ ("env", ) <$> mEnv
-        , ("environment", ) <$> mEnv -- Legacy
-        , ("service", ) <$> mService
-        , ("version", ) <$> mVersion
-        ]
+      [ ("env",) <$> mEnv
+      , ("environment",) <$> mEnv -- Legacy
+      , ("service",) <$> mService
+      , ("version",) <$> mVersion
+      ]
       <> tags
 
 newtype Gauges = Gauges
@@ -115,10 +113,10 @@
   }
 
 tagsL :: Lens' StatsClient [(Text, Text)]
-tagsL = lens scTags $ \x y -> x { scTags = y }
+tagsL = lens scTags $ \x y -> x {scTags = y}
 
 gaugesL :: Lens' StatsClient Gauges
-gaugesL = lens scGauges $ \x y -> x { scGauges = y }
+gaugesL = lens scGauges $ \x y -> x {scGauges = y}
 
 class HasStatsClient env where
   statsClientL :: Lens' env StatsClient
@@ -126,7 +124,7 @@
 instance HasStatsClient StatsClient where
   statsClientL = id
 
-instance HasStatsClient site =>  HasStatsClient (HandlerData child site) where
+instance HasStatsClient site => HasStatsClient (HandlerData child site) where
   statsClientL = envL . siteL . statsClientL
 
 withStatsClient
@@ -137,25 +135,29 @@
 withStatsClient StatsSettings {..} f = do
   gauges <- liftIO $ do
     gdbConnections <- Gauge "active_pool_connections" <$> EKG.new
-    pure Gauges { .. }
+    pure Gauges {..}
 
   if amsEnabled
     then do
       tags <- (amsTags <>) <$> getEcsMetadataTags
       Datadog.withDogStatsD amsSettings $ \client ->
         -- Add the tags to the thread context so they're present in all logs
-        withThreadContext (map toPair tags) $ f StatsClient
-          { scClient = client
-          , scTags = tags
+        withThreadContext (map toPair tags) $
+          f
+            StatsClient
+              { scClient = client
+              , scTags = tags
+              , scGauges = gauges
+              }
+    else do
+      f $
+        StatsClient
+          { scClient = Datadog.Dummy
+          , scTags = amsTags
           , scGauges = gauges
           }
-    else do
-      f $ StatsClient
-        { scClient = Datadog.Dummy
-        , scTags = amsTags
-        , scGauges = gauges
-        }
-  where toPair = bimap (fromString . unpack) String
+ where
+  toPair = bimap (fromString . unpack) String
 
 withGauge
   :: (MonadReader app m, HasStatsClient app, MonadUnliftIO m)
@@ -206,7 +208,6 @@
 --
 -- The 'ToMetricValue' constraint can be satisfied by most numeric types and is
 -- assumed to be seconds.
---
 histogram
   :: ( MonadUnliftIO m
      , MonadReader env m
@@ -231,7 +232,8 @@
   -> UTCTime
   -> m ()
 histogramSinceMs = histogramSinceBy toMilliseconds
-  where toMilliseconds = (* 1000) . realToFrac @_ @Double
+ where
+  toMilliseconds = (* 1000) . realToFrac @_ @Double
 
 histogramSinceBy
   :: ( MonadUnliftIO m
@@ -261,9 +263,9 @@
 sendMetric metricType name metricValue = do
   StatsClient {..} <- view statsClientL
 
-  Datadog.send scClient
-    $ Datadog.metric (Datadog.MetricName name) metricType metricValue
-    & (Datadog.tags .~ map (uncurry Datadog.tag) scTags)
+  Datadog.send scClient $
+    Datadog.metric (Datadog.MetricName name) metricType metricValue
+      & (Datadog.tags .~ map (uncurry Datadog.tag) scTags)
 
 getEcsMetadataTags :: MonadIO m => m [(Text, Text)]
 getEcsMetadataTags = do
@@ -272,16 +274,16 @@
  where
   err e = liftIO $ hPutStrLn stderr $ "Error reading ECS Metadata: " <> show e
 
-  toTags (EcsMetadata EcsContainerMetadata {..} EcsContainerTaskMetadata {..})
-    = [ ("container_id", ecmDockerId)
-      , ("container_name", ecmDockerName)
-      , ("docker_image", ecmImage)
-      , ("image_tag", ecmImageID)
-      , ("cluster_name", ectmCluster)
-      , ("task_arn", ectmTaskARN)
-      , ("task_family", ectmFamily)
-      , ("task_version", ectmRevision)
-      ]
+  toTags (EcsMetadata EcsContainerMetadata {..} EcsContainerTaskMetadata {..}) =
+    [ ("container_id", ecmDockerId)
+    , ("container_name", ecmDockerName)
+    , ("docker_image", ecmImage)
+    , ("image_tag", ecmImageID)
+    , ("cluster_name", ectmCluster)
+    , ("task_arn", ectmTaskARN)
+    , ("task_family", ectmFamily)
+    , ("task_version", ectmRevision)
+    ]
 
 -- $usage
 -- Usage:
@@ -353,4 +355,3 @@
 --         Stats.'increment' \"action.success\"
 --         -- ...
 --   @
---
diff --git a/library/Freckle/App/Stats/Rts.hs b/library/Freckle/App/Stats/Rts.hs
--- a/library/Freckle/App/Stats/Rts.hs
+++ b/library/Freckle/App/Stats/Rts.hs
@@ -16,7 +16,6 @@
 -- | Initialize a thread to poll RTS stats
 --
 -- Stats are collected via `ekg-core` and 'System.Metrics.registerGcMetrics'
---
 forkRtsStatPolling
   :: (MonadUnliftIO m, MonadReader env m, HasStatsClient env) => m ()
 forkRtsStatPolling = do
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 (..)
   , appExample
   , withApp
   , withAppSql
@@ -13,7 +13,7 @@
   , pending
   , pendingWith
 
-  -- * Re-exports
+    -- * Re-exports
   , module X
   ) where
 
@@ -39,9 +39,9 @@
 import Control.Monad.Base
 import Control.Monad.Catch
 import qualified Control.Monad.Fail as Fail
-import Control.Monad.IO.Unlift (MonadUnliftIO(..))
+import Control.Monad.IO.Unlift (MonadUnliftIO (..))
 import Control.Monad.Primitive
-import Control.Monad.Random (MonadRandom(..))
+import Control.Monad.Random (MonadRandom (..))
 import Control.Monad.Reader
 import Control.Monad.Trans.Control
 import Database.Persist.Sql (SqlPersistT, runSqlPool)
@@ -58,7 +58,6 @@
 --
 -- - 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
   }
@@ -92,9 +91,10 @@
   uninterruptibleMask = UnliftIO.uninterruptibleMask
   generalBracket acquire release use = mask $ \unmasked -> do
     resource <- acquire
-    b <- unmasked (use resource) `UnliftIO.catch` \e -> do
-      _ <- release resource (ExitCaseException e)
-      throwM e
+    b <-
+      unmasked (use resource) `UnliftIO.catch` \e -> do
+        _ <- release resource (ExitCaseException e)
+        throwM e
     c <- release resource (ExitCaseSuccess b)
     pure (b, c)
 
@@ -111,10 +111,11 @@
 instance HasLogger app => Example (AppExample app a) where
   type Arg (AppExample app a) = app
 
-  evaluateExample (AppExample ex) params action = evaluateExample
-    (action $ \app -> void $ runLoggerLoggingT app $ runReaderT ex app)
-    params
-    ($ ())
+  evaluateExample (AppExample ex) params action =
+    evaluateExample
+      (action $ \app -> void $ runLoggerLoggingT app $ runReaderT ex app)
+      params
+      ($ ())
 
 instance MonadTracer (AppExample app) where
   getVaultData = pure Nothing
@@ -126,7 +127,6 @@
 --
 -- This can be used to avoid ambiguity errors when your expectation uses only
 -- polymorphic functions like 'runDB' or lifted 'shouldBe' et-al.
---
 appExample :: AppExample app a -> AppExample app a
 appExample = id
 
@@ -139,7 +139,6 @@
 --
 -- Reads @.env.test@, then loads the application. Examples within this spec can
 -- use any @'MonadReader' app@ (including 'runDB', if the app 'HasSqlPool').
---
 withApp :: ((app -> IO ()) -> IO ()) -> SpecWith app -> Spec
 withApp run = beforeAll Dotenv.loadTest . Hspec.aroundAll run
 
@@ -147,7 +146,6 @@
 --
 -- Runs the given function on the pool before every spec item. For example, to
 -- truncate tables.
---
 withAppSql
   :: HasSqlPool app
   => SqlPersistT IO a
diff --git a/library/Freckle/App/Test/DocTest.hs b/library/Freckle/App/Test/DocTest.hs
--- a/library/Freckle/App/Test/DocTest.hs
+++ b/library/Freckle/App/Test/DocTest.hs
@@ -4,7 +4,7 @@
   ( doctest
   , doctestWith
 
-  -- * Lower-level, for site-specific use
+    -- * Lower-level, for site-specific use
   , findPackageFlags
   , findDocTestedFiles
   ) where
@@ -16,8 +16,8 @@
 import qualified Data.Text as T
 import qualified Data.Text.IO as T
 import Data.Yaml (decodeFileThrow)
-import "Glob" System.FilePath.Glob (globDir1)
 import qualified Test.DocTest as DocTest
+import "Glob" System.FilePath.Glob (globDir1)
 
 -- | Run doctest on files in the given directory
 doctest :: FilePath -> IO ()
@@ -37,8 +37,8 @@
   }
 
 instance FromJSON PackageYaml where
-  parseJSON = withObject "PackageYaml"
-    $ \o -> PackageYaml <$> o .: "default-extensions" <*> o .: "name"
+  parseJSON = withObject "PackageYaml" $
+    \o -> PackageYaml <$> o .: "default-extensions" <*> o .: "name"
 
 -- Parse @default-extensions@ and @name& out of @package.yaml@
 --
@@ -64,7 +64,6 @@
 --
 -- So we want to only run doctest for files that need it. This function finds
 -- such files by /naively/ looking for the @^-- >>>@ pattern.
---
 findDocTestedFiles :: FilePath -> IO [FilePath]
 findDocTestedFiles dir = do
   paths <- globDir1 "**/*.hs" dir
diff --git a/library/Freckle/App/Test/Hspec/Runner.hs b/library/Freckle/App/Test/Hspec/Runner.hs
--- a/library/Freckle/App/Test/Hspec/Runner.hs
+++ b/library/Freckle/App/Test/Hspec/Runner.hs
@@ -8,13 +8,15 @@
 import Freckle.App.Prelude
 
 import Control.Concurrent (getNumCapabilities, setNumCapabilities)
-import Control.Monad.Trans.Maybe (MaybeT(MaybeT), runMaybeT)
+import Control.Monad.Trans.Maybe (MaybeT (MaybeT), runMaybeT)
 import Data.List (isInfixOf)
-import qualified Prelude as Unsafe (read)
 import System.Environment (getArgs, lookupEnv)
 import Test.Hspec (Spec)
 import Test.Hspec.JUnit
-  (configWithJUnit, defaultJUnitConfig, setJUnitConfigOutputFile)
+  ( configWithJUnit
+  , defaultJUnitConfig
+  , setJUnitConfigOutputFile
+  )
 import Test.Hspec.Runner
   ( Config
   , Path
@@ -26,6 +28,7 @@
   , readConfig
   , runSpec
   )
+import qualified Prelude as Unsafe (read)
 
 run :: String -> Spec -> IO ()
 run = runWith defaultConfig
@@ -44,17 +47,21 @@
   -- Run unreliable tests first, so local dev errors are reported for reliable
   -- specs at the end
   putStrLn "Running UNRELIABLE tests; failures here should not fail the build"
-  void $ runner ("unreliable-" <> name) id =<< load
-    args
-    (skip reliableTests config)
+  void $
+    runner ("unreliable-" <> name) id
+      =<< load
+        args
+        (skip reliableTests config)
 
   putStrLn "Running RELIABLE"
-  reliableSummary <- runner name id
-    =<< load args (skip (anys [unreliableTests, isolatedTests]) config)
+  reliableSummary <-
+    runner name id
+      =<< load args (skip (anys [unreliableTests, isolatedTests]) config)
 
   putStrLn "Running ISOLATED"
-  isolatedSummary <- runner ("isolated-" <> name) noConcurrency
-    =<< load args (skip (not . isolatedTests) config)
+  isolatedSummary <-
+    runner ("isolated-" <> name) noConcurrency
+      =<< load args (skip (not . isolatedTests) config)
 
   evaluateSummary $ reliableSummary <> isolatedSummary
  where
@@ -62,7 +69,7 @@
   junit filename changeConfig =
     (spec `runJUnitSpec` ("/tmp/junit", filename)) . changeConfig
   hspec _ changeConfig = runSpec spec . changeConfig
-  noConcurrency x = x { configConcurrentJobs = Just 1 }
+  noConcurrency x = x {configConcurrentJobs = Just 1}
 
 runJUnitSpec :: Spec -> (FilePath, String) -> Config -> IO Summary
 runJUnitSpec spec (path, name) config =
@@ -74,13 +81,15 @@
 
 makeParallelConfig :: Config -> IO Config
 makeParallelConfig config = do
-  jobCores <- fromMaybe 1 <$> runMaybeT
-    (MaybeT lookupTestCapabilities <|> MaybeT lookupHostCapabilities)
+  jobCores <-
+    fromMaybe 1
+      <$> runMaybeT
+        (MaybeT lookupTestCapabilities <|> MaybeT lookupHostCapabilities)
   putStrLn $ "Running spec with " <> show jobCores <> " cores"
   setNumCapabilities jobCores
   -- Api specs are IO bound, having more jobs than cores allows for more
   -- cooperative IO from green thread interleaving.
-  pure config { configConcurrentJobs = Just $ jobCores * 4 }
+  pure config {configConcurrentJobs = Just $ jobCores * 4}
 
 lookupTestCapabilities :: IO (Maybe Int)
 lookupTestCapabilities = fmap Unsafe.read <$> lookupEnv "TEST_CAPABILITIES"
@@ -93,7 +102,7 @@
 reduceCapabilities = max 1 . (`div` 2)
 
 skip :: (Path -> Bool) -> Config -> Config
-skip predicate config = config { configSkipPredicate = Just predicate }
+skip predicate config = config {configSkipPredicate = Just predicate}
 
 unreliableTests :: Path -> Bool
 unreliableTests = ("UNRELIABLE" `isInfixOf`) . snd
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
@@ -3,14 +3,14 @@
   ( noCacheMiddleware
   , denyFrameEmbeddingMiddleware
 
-  -- * CORS
+    -- * CORS
   , corsMiddleware
 
-  -- * Logs
+    -- * Logs
   , requestLogger
   , addThreadContextFromRequest
 
-  -- * Metrics
+    -- * Metrics
   , addThreadContextFromStatsTags
   , requestStats
   ) where
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
@@ -7,7 +7,7 @@
 import Freckle.App.Prelude
 
 import Blammo.Logging
-import Database.PostgreSQL.Simple (SqlError(..))
+import Database.PostgreSQL.Simple (SqlError (..))
 import Freckle.App.Stats (HasStatsClient)
 import qualified Freckle.App.Stats as Stats
 import Network.HTTP.Types (ResponseHeaders, status503)
@@ -18,7 +18,6 @@
 -- | Catch 'SqlError' when queries are canceled due to timeout and respond 503
 --
 -- Also logs and increments a metric.
---
 respondQueryCanceled
   :: HasStatsClient site => HandlerFor site res -> HandlerFor site res
 respondQueryCanceled = respondQueryCanceledHeaders []
diff --git a/library/Freckle/App/Yesod/Routes.hs b/library/Freckle/App/Yesod/Routes.hs
--- a/library/Freckle/App/Yesod/Routes.hs
+++ b/library/Freckle/App/Yesod/Routes.hs
@@ -20,7 +20,6 @@
 -- > \case
 -- >   RoutePiece a -> case a of
 -- >     RouteResource{} -> "ResourceName"
---
 mkRouteNameCaseExp :: [ResourceTree String] -> TH.Q TH.Exp
 mkRouteNameCaseExp tree = TH.LamCaseE . fold <$> traverse mkMatches tree
 
@@ -28,15 +27,13 @@
 --
 -- > RoutePiece a -> case a of
 -- >   ...
---
 mkMatches :: ResourceTree String -> TH.Q [TH.Match]
 mkMatches (ResourceLeaf resource) = pure [mkLeafMatch resource]
 mkMatches (ResourceParent name _checkOverlap params children) = do
   caseVar <- TH.newName "a"
-  let
-    -- by convention the final param in a route is the next route constructor
-    paramVars =
-      fmap (const TH.WildP) (filter isDynamic params) <> [TH.VarP caseVar]
+  let -- by convention the final param in a route is the next route constructor
+      paramVars =
+        fmap (const TH.WildP) (filter isDynamic params) <> [TH.VarP caseVar]
   matches <- fold <$> traverse mkMatches children
   pure
     [ TH.Match
@@ -44,7 +41,8 @@
         (TH.NormalB $ TH.CaseE (TH.VarE caseVar) matches)
         []
     ]
-  where constName = TH.mkName name
+ where
+  constName = TH.mkName name
 
 conP :: TH.Name -> [TH.Pat] -> TH.Pat
 #if MIN_VERSION_template_haskell(2,18,0)
@@ -55,18 +53,18 @@
 
 isDynamic :: Piece a -> Bool
 isDynamic = \case
-  Static{} -> False
-  Dynamic{} -> True
+  Static {} -> False
+  Dynamic {} -> True
 
 -- | Leaf match expressions for a resource
 --
 -- > Name{} -> "ResourceName"
---
 mkLeafMatch :: Resource String -> TH.Match
-mkLeafMatch resource = TH.Match
-  (TH.RecP constName [])
-  (TH.NormalB $ TH.LitE $ TH.StringL name)
-  []
+mkLeafMatch resource =
+  TH.Match
+    (TH.RecP constName [])
+    (TH.NormalB $ TH.LitE $ TH.StringL name)
+    []
  where
   constName = TH.mkName name
   name = resourceName resource
diff --git a/library/Network/HTTP/Link/Compat.hs b/library/Network/HTTP/Link/Compat.hs
--- a/library/Network/HTTP/Link/Compat.hs
+++ b/library/Network/HTTP/Link/Compat.hs
@@ -1,10 +1,11 @@
 {-# LANGUAGE CPP #-}
+
 module Network.HTTP.Link.Compat
-    ( Link
-    , linkURI
-    , parseLinkURI
-    , module Network.HTTP.Link
-    )
+  ( Link
+  , linkURI
+  , parseLinkURI
+  , module Network.HTTP.Link
+  )
 where
 
 import Prelude
diff --git a/library/Network/Wai/Middleware/Cors.hs b/library/Network/Wai/Middleware/Cors.hs
--- a/library/Network/Wai/Middleware/Cors.hs
+++ b/library/Network/Wai/Middleware/Cors.hs
@@ -26,11 +26,13 @@
 handleOptions :: (ByteString -> Bool) -> [ByteString] -> Middleware
 handleOptions validateOrigin extraExposedHeaders app req sendResponse =
   case (requestMethod req, lookup "Origin" (requestHeaders req)) of
-    ("OPTIONS", Just origin) -> sendResponse $ responseLBS
-      status200
-      (toHeaders $ corsResponseHeaders validateOrigin extraExposedHeaders origin
-      )
-      mempty
+    ("OPTIONS", Just origin) ->
+      sendResponse $
+        responseLBS
+          status200
+          ( toHeaders $ corsResponseHeaders validateOrigin extraExposedHeaders origin
+          )
+          mempty
     _ -> app req sendResponse
  where
   toHeaders :: [(ByteString, ByteString)] -> ResponseHeaders
@@ -40,11 +42,12 @@
 addCORSHeaders validateOrigin extraExposedHeaders app req sendResponse =
   case lookup "Origin" (requestHeaders req) of
     Nothing -> app req sendResponse
-    Just origin -> addHeaders
-      (corsResponseHeaders validateOrigin extraExposedHeaders origin)
-      app
-      req
-      sendResponse
+    Just origin ->
+      addHeaders
+        (corsResponseHeaders validateOrigin extraExposedHeaders origin)
+        app
+        req
+        sendResponse
 
 corsResponseHeaders
   :: (ByteString -> Bool)
diff --git a/library/Network/Wai/Middleware/Stats.hs b/library/Network/Wai/Middleware/Stats.hs
--- a/library/Network/Wai/Middleware/Stats.hs
+++ b/library/Network/Wai/Middleware/Stats.hs
@@ -10,9 +10,9 @@
 import Control.Monad.Reader (runReaderT)
 import Data.Aeson ((.=))
 import qualified Freckle.App.Aeson as Key
-import Freckle.App.Stats (HasStatsClient(..), tagsL)
+import Freckle.App.Stats (HasStatsClient (..), tagsL)
 import qualified Freckle.App.Stats as Stats
-import Network.HTTP.Types.Status (Status(..))
+import Network.HTTP.Types.Status (Status (..))
 import Network.Wai (Middleware, Request, requestMethod, responseStatus)
 
 -- | Add any tags in the ambient 'StatsClient' to the logging context
@@ -30,12 +30,11 @@
 requestStats env getTags app req respond = do
   start <- getCurrentTime
   app req $ \res -> do
-    let
-      tags =
-        getTags req
-          <> [ ("method", decodeUtf8 $ requestMethod req)
-             , ("status", pack $ show $ statusCode $ responseStatus res)
-             ]
+    let tags =
+          getTags req
+            <> [ ("method", decodeUtf8 $ requestMethod req)
+               , ("status", pack $ show $ statusCode $ responseStatus res)
+               ]
 
     flip runReaderT env $ Stats.tagged tags $ do
       Stats.increment "requests"
diff --git a/library/Yesod/Core/Lens.hs b/library/Yesod/Core/Lens.hs
--- a/library/Yesod/Core/Lens.hs
+++ b/library/Yesod/Core/Lens.hs
@@ -9,7 +9,7 @@
 import Yesod.Core.Types (HandlerData, RunHandlerEnv, handlerEnv, rheSite)
 
 envL :: Lens' (HandlerData child site) (RunHandlerEnv child site)
-envL = lens handlerEnv $ \x y -> x { handlerEnv = y }
+envL = lens handlerEnv $ \x y -> x {handlerEnv = y}
 
 siteL :: Lens' (RunHandlerEnv child site) site
-siteL = lens rheSite $ \x y -> x { rheSite = y }
+siteL = lens rheSite $ \x y -> x {rheSite = y}
diff --git a/package.yaml b/package.yaml
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
 name: freckle-app
-version: 1.9.0.3
+version: 1.9.1.0
 maintainer: Freckle Education
 category: Utils
 github: freckle/freckle-app
@@ -99,6 +99,7 @@
     - http-conduit >= 2.3.5 # addToRequestQueryString
     - http-link-header
     - http-types
+    - hw-kafka-client
     - immortal
     - lens
     - memcache
diff --git a/tests/Freckle/App/Bugsnag/MetaDataSpec.hs b/tests/Freckle/App/Bugsnag/MetaDataSpec.hs
--- a/tests/Freckle/App/Bugsnag/MetaDataSpec.hs
+++ b/tests/Freckle/App/Bugsnag/MetaDataSpec.hs
@@ -24,13 +24,13 @@
           withThreadContext ["quix" .= ("quip" :: Text)] $ do
             collected <- collectMetaData
 
-            let
-              expected = mconcat
-                [ metaData
-                  "tags"
-                  ["foo" .= ("bar" :: Text), "baz" .= ("bat" :: Text)]
-                , metaData "context" ["quix" .= ("quip" :: Text)]
-                ]
+            let expected =
+                  mconcat
+                    [ metaData
+                        "tags"
+                        ["foo" .= ("bar" :: Text), "baz" .= ("bat" :: Text)]
+                    , metaData "context" ["quix" .= ("quip" :: Text)]
+                    ]
 
             runMergeMetaData Nothing collected `shouldBe` Just expected
 
@@ -42,34 +42,37 @@
 
     it "preserves new metadata on collisions" $ example $ do
       let
-        existing = metaData
-          "test"
-          [ "foo" .= ("bar1" :: Text)
-          , "baz" .= object ["bat" .= ("quix1" :: Text)]
-          , "keep" .= ("me" :: Text)
-          ]
-        incoming = metaData
-          "test"
-          [ "foo" .= ("bar2" :: Text)
-          , "baz" .= object ["bat" .= ("quix2" :: Text)]
-          , "add" .= ("me" :: Text)
-          ]
-        expected = metaData
-          "test"
-          [ "foo" .= ("bar2" :: Text)
-          , "baz" .= object ["bat" .= ("quix2" :: Text)]
-          , "keep" .= ("me" :: Text)
-          , "add" .= ("me" :: Text)
-          ]
-
+        existing =
+          metaData
+            "test"
+            [ "foo" .= ("bar1" :: Text)
+            , "baz" .= object ["bat" .= ("quix1" :: Text)]
+            , "keep" .= ("me" :: Text)
+            ]
+        incoming =
+          metaData
+            "test"
+            [ "foo" .= ("bar2" :: Text)
+            , "baz" .= object ["bat" .= ("quix2" :: Text)]
+            , "add" .= ("me" :: Text)
+            ]
+        expected =
+          metaData
+            "test"
+            [ "foo" .= ("bar2" :: Text)
+            , "baz" .= object ["bat" .= ("quix2" :: Text)]
+            , "keep" .= ("me" :: Text)
+            , "add" .= ("me" :: Text)
+            ]
 
       runMergeMetaData (Just existing) incoming `shouldBe` Just expected
 
 runMergeMetaData :: Maybe MetaData -> MetaData -> Maybe MetaData
 runMergeMetaData mExisting incoming = MetaData <$> event_metaData event
  where
-  event = runBeforeNotify (mergeMetaData incoming) someException
-    $ defaultEvent { event_metaData = unMetaData <$> mExisting }
+  event =
+    runBeforeNotify (mergeMetaData incoming) someException $
+      defaultEvent {event_metaData = unMetaData <$> mExisting}
 
 someException :: SomeException
 someException = undefined
diff --git a/tests/Freckle/App/BugsnagSpec.hs b/tests/Freckle/App/BugsnagSpec.hs
--- a/tests/Freckle/App/BugsnagSpec.hs
+++ b/tests/Freckle/App/BugsnagSpec.hs
@@ -5,7 +5,7 @@
 import Freckle.App.Prelude
 
 import Data.ByteString (ByteString)
-import Database.PostgreSQL.Simple (ExecStatus(..), SqlError(..))
+import Database.PostgreSQL.Simple (ExecStatus (..), SqlError (..))
 import Freckle.App.Bugsnag
 import Test.Hspec
 
@@ -14,21 +14,24 @@
   describe "sqlErrorGroupingHash" $ do
     it "groups duplicate key errors by constraint name" $ do
       let
-        err1 = sqlError
-          { sqlState = duplicateKeyState
-          , sqlErrorMsg = duplicateKeyErrorMsg "table_a_id_key"
-          , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
-          }
-        err2 = sqlError
-          { sqlState = duplicateKeyState
-          , sqlErrorMsg = duplicateKeyErrorMsg "table_a_id_key"
-          , sqlErrorDetail = "Key (a, b)=(3, 2) already exists"
-          }
-        err3 = sqlError
-          { sqlState = duplicateKeyState
-          , sqlErrorMsg = duplicateKeyErrorMsg "table_b_id_key"
-          , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
-          }
+        err1 =
+          sqlError
+            { sqlState = duplicateKeyState
+            , sqlErrorMsg = duplicateKeyErrorMsg "table_a_id_key"
+            , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
+            }
+        err2 =
+          sqlError
+            { sqlState = duplicateKeyState
+            , sqlErrorMsg = duplicateKeyErrorMsg "table_a_id_key"
+            , sqlErrorDetail = "Key (a, b)=(3, 2) already exists"
+            }
+        err3 =
+          sqlError
+            { sqlState = duplicateKeyState
+            , sqlErrorMsg = duplicateKeyErrorMsg "table_b_id_key"
+            , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
+            }
 
       -- All duplicate keys hashed
       sqlErrorGroupingHash err1 `shouldSatisfy` isJust
@@ -42,26 +45,30 @@
 
     it "groups foreign key errors by constraint name" $ do
       let
-        err1 = sqlError
-          { sqlState = foreignKeyState
-          , sqlErrorMsg = foreignKeyErrorMsg "table_a" "table_b_id_fkey"
-          , sqlErrorDetail = "Key (a, b)=(1, 2) is still referenced"
-          }
-        err2 = sqlError
-          { sqlState = foreignKeyState
-          , sqlErrorMsg = foreignKeyErrorMsg "table_a" "table_b_id_fkey"
-          , sqlErrorDetail = "Key (a, b)=(3, 2) is still referenced"
-          }
-        err3 = sqlError
-          { sqlState = foreignKeyState
-          , sqlErrorMsg = foreignKeyErrorMsg "table_b" "table_b_id_key"
-          , sqlErrorDetail = "Key (a, b)=(1, 2) is still referenced"
-          }
-        err4 = sqlError
-          { sqlState = foreignKeyState
-          , sqlErrorMsg = foreignKeyErrorMsg "table_a" "table_a_id_key"
-          , sqlErrorDetail = "Key (a, b)=(1, 2) is still referenced"
-          }
+        err1 =
+          sqlError
+            { sqlState = foreignKeyState
+            , sqlErrorMsg = foreignKeyErrorMsg "table_a" "table_b_id_fkey"
+            , sqlErrorDetail = "Key (a, b)=(1, 2) is still referenced"
+            }
+        err2 =
+          sqlError
+            { sqlState = foreignKeyState
+            , sqlErrorMsg = foreignKeyErrorMsg "table_a" "table_b_id_fkey"
+            , sqlErrorDetail = "Key (a, b)=(3, 2) is still referenced"
+            }
+        err3 =
+          sqlError
+            { sqlState = foreignKeyState
+            , sqlErrorMsg = foreignKeyErrorMsg "table_b" "table_b_id_key"
+            , sqlErrorDetail = "Key (a, b)=(1, 2) is still referenced"
+            }
+        err4 =
+          sqlError
+            { sqlState = foreignKeyState
+            , sqlErrorMsg = foreignKeyErrorMsg "table_a" "table_a_id_key"
+            , sqlErrorDetail = "Key (a, b)=(1, 2) is still referenced"
+            }
 
       -- All errors hashed
       sqlErrorGroupingHash err1 `shouldSatisfy` isJust
@@ -78,43 +85,44 @@
       sqlErrorGroupingHash err3 `shouldNotBe` sqlErrorGroupingHash err4
 
     it "does not group other SqlErrors" $ do
-      let
-        err = sqlError
-          { sqlState = "9999"
-          , sqlErrorMsg = duplicateKeyErrorMsg "table_a_id_key"
-          , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
-          }
+      let err =
+            sqlError
+              { sqlState = "9999"
+              , sqlErrorMsg = duplicateKeyErrorMsg "table_a_id_key"
+              , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
+              }
 
       sqlErrorGroupingHash err `shouldBe` Nothing
 
     it "does not group other states" $ do
-      let
-        err = sqlError
-          { sqlState = "12345"
-          , sqlErrorMsg = "State is wrong, \"table_a_id_key\""
-          , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
-          }
+      let err =
+            sqlError
+              { sqlState = "12345"
+              , sqlErrorMsg = "State is wrong, \"table_a_id_key\""
+              , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
+              }
 
       sqlErrorGroupingHash err `shouldBe` Nothing
 
     it "does not group when unable to parse" $ do
-      let
-        err = sqlError
-          { sqlState = foreignKeyState
-          , sqlErrorMsg = "FK needs two quoted values, \"table_a_id_fkey\""
-          , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
-          }
+      let err =
+            sqlError
+              { sqlState = foreignKeyState
+              , sqlErrorMsg = "FK needs two quoted values, \"table_a_id_fkey\""
+              , sqlErrorDetail = "Key (a, b)=(1, 2) already exists"
+              }
 
       sqlErrorGroupingHash err `shouldBe` Nothing
 
 sqlError :: SqlError
-sqlError = SqlError
-  { sqlState = ""
-  , sqlExecStatus = FatalError
-  , sqlErrorMsg = ""
-  , sqlErrorDetail = ""
-  , sqlErrorHint = ""
-  }
+sqlError =
+  SqlError
+    { sqlState = ""
+    , sqlExecStatus = FatalError
+    , sqlErrorMsg = ""
+    , sqlErrorDetail = ""
+    , sqlErrorHint = ""
+    }
 
 duplicateKeyState :: ByteString
 duplicateKeyState = "23505"
diff --git a/tests/Freckle/App/CsvSpec.hs b/tests/Freckle/App/CsvSpec.hs
--- a/tests/Freckle/App/CsvSpec.hs
+++ b/tests/Freckle/App/CsvSpec.hs
@@ -52,21 +52,21 @@
       header = ["someInt", "someText"]
       rows :: [Foo]
       rows = [Foo 123 "this is some text", Foo 456 "this is more text"]
-    it "should parse records as expected"
-      $ decode HasHeader content
-      `shouldBe` Right (V.fromList rows)
+    it "should parse records as expected" $
+      decode HasHeader content
+        `shouldBe` Right (V.fromList rows)
 
-    it "should parse named records as expected"
-      $ decodeByName content
-      `shouldBe` Right (V.fromList header, V.fromList rows)
+    it "should parse named records as expected" $
+      decodeByName content
+        `shouldBe` Right (V.fromList header, V.fromList rows)
 
-    it "should serialize records as expected"
-      $ encode rows
-      `shouldBe` contentRows
+    it "should serialize records as expected" $
+      encode rows
+        `shouldBe` contentRows
 
-    it "should serialize named records as expected"
-      $ encodeDefaultOrderedByName rows
-      `shouldBe` content
+    it "should serialize named records as expected" $
+      encodeDefaultOrderedByName rows
+        `shouldBe` content
 
   for_ [CustomParser, FromNamedRecordParser] $ \testType -> do
     describe (csvSinkTestName testType) $ do
@@ -78,8 +78,9 @@
 
       it "should generate errors for parse errors" $ do
         result <- csvSinkTest testType (yieldMany []) pure
-        runValidate result `shouldBe` Left
-          (pure $ CsvParseException @Void 1 "parse error (not enough input)")
+        runValidate result
+          `shouldBe` Left
+            (pure $ CsvParseException @Void 1 "parse error (not enough input)")
 
       it "should generate errors per row" $ do
         let
@@ -98,7 +99,6 @@
         first (fmap errorLine) (runValidate result)
           `shouldBe` Left (NE.fromList [2, 4, 5])
 
-
       it "should generate errors for non-utf8-encoded files" $ do
         let source = yieldMany [BS.pack [0XFF, 0XFF, 0XFF, 0XFF]]
         result <- csvSinkTest testType source pure
@@ -109,34 +109,37 @@
           validate foo@Foo {..}
             | someInt < 4 = pure foo
             | otherwise = refute $ pure $ CsvExceptionExtension BadFoo
-          source = yieldMany
-            [ "someInt,someText\n"
-            , "3,meep\n"
-            , "4,morp\n"
-            , "1,whomp\n"
-            , "5,merp\n"
-            ]
+          source =
+            yieldMany
+              [ "someInt,someText\n"
+              , "3,meep\n"
+              , "4,morp\n"
+              , "1,whomp\n"
+              , "5,merp\n"
+              ]
 
         result <- csvSinkTest testType source $ traverse validate
         runValidate result
           `shouldBe` Left
-                       (NE.fromList
-                         [ CsvExceptionExtension BadFoo
-                         , CsvExceptionExtension BadFoo
-                         ]
-                       )
+            ( NE.fromList
+                [ CsvExceptionExtension BadFoo
+                , CsvExceptionExtension BadFoo
+                ]
+            )
 
       it "should successfully parse a CSV" $ do
         let
           validate foo@Foo {..}
             | someInt < 4 = pure foo
             | otherwise = refute $ pure $ CsvExceptionExtension BadFoo
-          source = yieldMany
-            ["someInt,someText\n", "1,meep\n", "2,morp\n", "3,merp\n"]
+          source =
+            yieldMany
+              ["someInt,someText\n", "1,meep\n", "2,morp\n", "3,merp\n"]
 
         result <- csvSinkTest testType source $ traverse validate
-        runValidate result `shouldBe` Right
-          (V.fromList [Foo 1 "meep", Foo 2 "morp", Foo 3 "merp"])
+        runValidate result
+          `shouldBe` Right
+            (V.fromList [Foo 1 "meep", Foo 2 "morp", Foo 3 "merp"])
 
       it "should successfully parse a CSV with weird whitespace" $ do
         let
@@ -152,8 +155,9 @@
               ]
 
         result <- csvSinkTest testType source $ traverse validate
-        runValidate result `shouldBe` Right
-          (V.fromList [Foo 1 "meep", Foo 2 "morp", Foo 3 "merp"])
+        runValidate result
+          `shouldBe` Right
+            (V.fromList [Foo 1 "meep", Foo 2 "morp", Foo 3 "merp"])
 
 data CSVSinkTest = CustomParser | FromNamedRecordParser
 
@@ -174,7 +178,7 @@
   => CSVSinkTest
   -> ConduitT () ByteString (ResourceT m) ()
   -> ( Vector Foo
-     -> Validate (NonEmpty (CsvException err)) (Vector Foo)
+       -> Validate (NonEmpty (CsvException err)) (Vector Foo)
      )
   -> m (Validate (NonEmpty (CsvException err)) (Vector Foo))
 csvSinkTest = \case
diff --git a/tests/Freckle/App/HttpSpec.hs b/tests/Freckle/App/HttpSpec.hs
--- a/tests/Freckle/App/HttpSpec.hs
+++ b/tests/Freckle/App/HttpSpec.hs
@@ -4,7 +4,7 @@
 
 import Prelude
 
-import Control.Lens (_Left, _Right, to, (^?))
+import Control.Lens (to, (^?), _Left, _Right)
 import Data.Aeson
 import Data.Aeson.Lens
 import qualified Data.List.NonEmpty as NE
@@ -16,30 +16,31 @@
 spec = do
   describe "httpJson" $ do
     it "fetches JSON via HTTP" $ do
-      resp <- httpJson @_ @Value
-        $ parseRequest_ "https://www.stackage.org/lts-17.10"
+      resp <-
+        httpJson @_ @Value $
+          parseRequest_ "https://www.stackage.org/lts-17.10"
 
       getResponseStatus resp `shouldBe` status200
       getResponseBody resp
         ^? _Right
-        . key "snapshot"
-        . key "ghc"
-        . _String
+          . key "snapshot"
+          . key "ghc"
+          . _String
         `shouldBe` Just "8.10.4"
 
     it "places JSON parse errors in a Left body" $ do
-      resp <- httpJson @_ @[()]
-        $ parseRequest_ "https://www.stackage.org/lts-17.10"
+      resp <-
+        httpJson @_ @[()] $
+          parseRequest_ "https://www.stackage.org/lts-17.10"
 
-      let
-        expectedErrorMessages =
-          [ "Error in $: expected [a], encountered Object"
-          , "Error in $: parsing [] failed, expected Array, but encountered Object"
-          ]
+      let expectedErrorMessages =
+            [ "Error in $: expected [a], encountered Object"
+            , "Error in $: parsing [] failed, expected Array, but encountered Object"
+            ]
 
       getResponseStatus resp `shouldBe` status200
       getResponseBody resp
         ^? _Left
-        . to hdeErrors
-        . to NE.head
+          . to hdeErrors
+          . to NE.head
         `shouldSatisfy` maybe False (`elem` expectedErrorMessages)
diff --git a/tests/Freckle/App/Memcached/ServersSpec.hs b/tests/Freckle/App/Memcached/ServersSpec.hs
--- a/tests/Freckle/App/Memcached/ServersSpec.hs
+++ b/tests/Freckle/App/Memcached/ServersSpec.hs
@@ -64,9 +64,9 @@
         `shouldBe` Just (Memcache.Auth "user" "password")
 
     it "can do all of this for a list of servers" $ example $ do
-      let
-        mServerSpecs = readServerSpecs
-          "memcached://a-host,memcached://b-host:11212,memcached://u:p@:11213"
+      let mServerSpecs =
+            readServerSpecs
+              "memcached://a-host,memcached://b-host:11212,memcached://u:p@:11213"
 
       fmap (map Memcache.ssHost) mServerSpecs
         `shouldBe` Just ["a-host", "b-host", defaultHost]
@@ -74,7 +74,7 @@
         `shouldBe` Just ["11211", "11212", "11213"]
       fmap (map Memcache.ssAuth) mServerSpecs
         `shouldBe` Just
-                     [Memcache.NoAuth, Memcache.NoAuth, Memcache.Auth "u" "p"]
+          [Memcache.NoAuth, Memcache.NoAuth, Memcache.Auth "u" "p"]
 
 readServerSpec :: String -> Maybe Memcache.ServerSpec
 readServerSpec = headMay <=< readServerSpecs
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
@@ -9,7 +9,7 @@
 import Blammo.Logging.LogSettings
 import Blammo.Logging.Logger
 import Control.Lens (lens)
-import Data.Aeson (Value(..))
+import Data.Aeson (Value (..))
 import Data.Aeson.Compat as KeyMap
 import qualified Data.List.NonEmpty as NE
 import qualified Freckle.App.Env as Env
@@ -43,20 +43,22 @@
 
 instance HasMemcachedClient App where
   memcachedClientL =
-    lens appMemcachedClient $ \x y -> x { appMemcachedClient = y }
+    lens appMemcachedClient $ \x y -> x {appMemcachedClient = y}
 
 instance HasLogger App where
-  loggerL = lens appLogger $ \x y -> x { appLogger = y }
+  loggerL = lens appLogger $ \x y -> x {appLogger = y}
 
 loadApp :: (App -> IO a) -> IO a
 loadApp f = do
-  servers <- Env.parse id $ Env.var
-    (Env.eitherReader readMemcachedServers)
-    "MEMCACHED_SERVERS"
-    (Env.def defaultMemcachedServers)
+  servers <-
+    Env.parse id $
+      Env.var
+        (Env.eitherReader readMemcachedServers)
+        "MEMCACHED_SERVERS"
+        (Env.def defaultMemcachedServers)
   appLogger <- newTestLogger defaultLogSettings
   withMemcachedClient servers $ \appMemcachedClient -> do
-    f App { .. }
+    f App {..}
 
 spec :: Spec
 spec = withApp loadApp $ do
@@ -84,7 +86,8 @@
       msgs <- getLoggedMessagesLenient
       let Just LoggedMessage {..} = NE.last <$> NE.nonEmpty msgs
       loggedMessageText `shouldBe` "Error deserializing"
-      loggedMessageMeta `shouldBe` KeyMap.fromList
-        [ ("action", String "deserializing")
-        , ("message", String "invalid: \"Broken\"")
-        ]
+      loggedMessageMeta
+        `shouldBe` KeyMap.fromList
+          [ ("action", String "deserializing")
+          , ("message", String "invalid: \"Broken\"")
+          ]
diff --git a/tests/Freckle/App/Test/Properties/JSONSpec.hs b/tests/Freckle/App/Test/Properties/JSONSpec.hs
--- a/tests/Freckle/App/Test/Properties/JSONSpec.hs
+++ b/tests/Freckle/App/Test/Properties/JSONSpec.hs
@@ -23,6 +23,6 @@
   describe "prop_roundTripJSON" $ do
     it "round trips for Integer" $ property $ prop_roundTripJSON @Integer
     it "round trips for Bool" $ property $ prop_roundTripJSON @Bool
-    it "round trips for some custom type"
-      $ property
-      $ prop_roundTripJSON @MyCustomType
+    it "round trips for some custom type" $
+      property $
+        prop_roundTripJSON @MyCustomType
diff --git a/tests/Freckle/App/Test/Properties/PathPieceSpec.hs b/tests/Freckle/App/Test/Properties/PathPieceSpec.hs
--- a/tests/Freckle/App/Test/Properties/PathPieceSpec.hs
+++ b/tests/Freckle/App/Test/Properties/PathPieceSpec.hs
@@ -13,6 +13,6 @@
   describe "pathPieceInstancesRoundTrip" $ do
     it "round trips for Integer" $ property $ prop_roundTripPathPiece @Integer
     it "round trips for Bool" $ property $ prop_roundTripPathPiece @Bool
-    it "round trips for Maybe String"
-      $ property
-      $ prop_roundTripPathPiece @(Maybe String)
+    it "round trips for Maybe String" $
+      property $
+        prop_roundTripPathPiece @(Maybe String)
diff --git a/tests/Freckle/App/WaiSpec.hs b/tests/Freckle/App/WaiSpec.hs
--- a/tests/Freckle/App/WaiSpec.hs
+++ b/tests/Freckle/App/WaiSpec.hs
@@ -8,7 +8,10 @@
 import Data.Function (on)
 import Data.List (deleteBy)
 import Freckle.App.Wai
-  (corsMiddleware, denyFrameEmbeddingMiddleware, noCacheMiddleware)
+  ( corsMiddleware
+  , denyFrameEmbeddingMiddleware
+  , noCacheMiddleware
+  )
 import Network.HTTP.Types.Method (Method)
 import Network.HTTP.Types.Status (status200)
 import Network.Wai
@@ -42,17 +45,23 @@
         runSession f $ corsMiddleware validateOrigin extraExposedHeaders app
 
     it "adds CORS headers to responses for non-OPTIONS" $ runTestSession $ do
-      response <- request $ setMethod "GET" $ setOriginHeader
-        "unimportant"
-        defaultRequest
+      response <-
+        request $
+          setMethod "GET" $
+            setOriginHeader
+              "unimportant"
+              defaultRequest
 
       assertAccessControlHeaders "BADORIGIN" response
       assertBody "Test" response
 
     it "responds itself, with CORS headers, for OPTIONS" $ runTestSession $ do
-      response <- request $ setMethod "OPTIONS" $ setOriginHeader
-        "unimportant"
-        defaultRequest
+      response <-
+        request $
+          setMethod "OPTIONS" $
+            setOriginHeader
+              "unimportant"
+              defaultRequest
 
       assertAccessControlHeaders "BADORIGIN" response
       assertBody mempty response
@@ -75,9 +84,9 @@
       assertBody "Test" responseA
       assertBody "Test" responseB
 
-    it "adds extra Exposed-Headers"
-      $ runTestSessionWith (const False) ["X-Foo"]
-      $ do
+    it "adds extra Exposed-Headers" $
+      runTestSessionWith (const False) ["X-Foo"] $
+        do
           response <- request $ setOriginHeader "unimportant" defaultRequest
           assertHeader
             "Access-Control-Expose-Headers"
@@ -99,14 +108,15 @@
 app _req respond = respond $ responseLBS status200 [] "Test"
 
 setMethod :: Method -> Request -> Request
-setMethod method req = req { requestMethod = method }
+setMethod method req = req {requestMethod = method}
 
 setOriginHeader :: ByteString -> Request -> Request
 setOriginHeader origin req =
   let
     header = ("Origin", origin)
     others = deleteBy ((==) `on` fst) header $ requestHeaders req
-  in req { requestHeaders = others <> [header] }
+  in
+    req {requestHeaders = others <> [header]}
 
 assertAccessControlHeaders :: ByteString -> SResponse -> Session ()
 assertAccessControlHeaders origin response = do
