freckle-kafka-0.1.1.0: library/Freckle/App/Kafka/Producer/Hooks.hs
-- | Observability hooks for Kafka producers
--
-- By default, a producer created by 'Freckle.App.Kafka.Producer.createKafkaProducerPool'
-- only reports /local/ enqueue failures (the result of @produceMessage@).
-- Whether a message actually reached the broker is reported asynchronously by
-- librdkafka, through a delivery report, and client-level problems (broker
-- down, auth failure, ...) through an error callback. This module wraps both
-- so that failures can be logged and counted.
--
-- /Note: librdkafka only runs these callbacks when the producer is polled./
-- @hw-kafka-client@ polls after each @produceMessage@ and when the producer is
-- closed or flushed, but not in the background. A report for a message may
-- therefore be delayed until the next message is produced on the same
-- producer, or until the producer is closed.
module Freckle.App.Kafka.Producer.Hooks
( ProducerHooks (..)
, loggingProducerHooks
, producerHooksProperties
) where
import Prelude
import Blammo.Logging (Message ((:#)), logErrorNS, (.=))
import Control.Monad.Logger.Aeson (LoggingT)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8With)
import Data.Text.Encoding.Error (lenientDecode)
import Kafka.Producer
( DeliveryReport (..)
, KafkaError
, ProducerProperties
, ProducerRecord (..)
, TopicName (..)
, deliveryCallback
, errorCallback
, setCallback
)
import UnliftIO.Exception (handleAny)
-- | Actions to run on events reported by librdkafka
--
-- These run on a librdkafka thread, so they should be quick, and they are
-- called in the order they were combined with '<>'. An exception thrown by a
-- hook is swallowed, so a faulty hook can't stop delivery reports being
-- processed.
data ProducerHooks = ProducerHooks
{ onDeliveryReport :: DeliveryReport -> IO ()
-- ^ Called once per message, with success or failure
--
-- Use this to count sent and failed events, for example.
, onProducerError :: KafkaError -> String -> IO ()
-- ^ Called for client-level errors, with librdkafka's message
}
instance Semigroup ProducerHooks where
a <> b =
ProducerHooks
{ onDeliveryReport = \r -> onDeliveryReport a r *> onDeliveryReport b r
, onProducerError = \e m -> onProducerError a e m *> onProducerError b e m
}
instance Monoid ProducerHooks where
mempty =
ProducerHooks
{ onDeliveryReport = \_ -> pure ()
, onProducerError = \_ _ -> pure ()
}
-- | Hooks that log failures at error level, under the @kafka@ namespace
--
-- Hooks run in 'IO' on a librdkafka thread, so the caller supplies how to run
-- logging there, such as @runLoggerLoggingT logger@ from Blammo.
--
-- Successful deliveries are not logged. The event payload is never logged,
-- only the topic, partition and key.
loggingProducerHooks :: (LoggingT IO () -> IO ()) -> ProducerHooks
loggingProducerHooks runLogging =
ProducerHooks
{ onDeliveryReport = \case
DeliverySuccess {} -> pure ()
DeliveryFailure record err ->
runLogging
$ logErrorNS "kafka"
$ "Failed to deliver event"
:# [ "topic" .= unTopicName (prTopic record)
, "partition" .= T.pack (show (prPartition record))
, "key" .= fmap (decodeUtf8With lenientDecode) (prKey record)
, "error" .= T.pack (show err)
]
NoMessageError err ->
runLogging
$ logErrorNS "kafka"
$ "Delivery report without a message"
:# ["error" .= T.pack (show err)]
, onProducerError = \err message ->
runLogging
$ logErrorNS "kafka"
$ "Kafka producer error"
:# ["error" .= T.pack (show err), "message" .= T.pack message]
}
-- | Register the hooks as callbacks on a producer
--
-- Replaces the default delivery callback, which ignores reports.
producerHooksProperties :: ProducerHooks -> ProducerProperties
producerHooksProperties hooks =
setCallback (deliveryCallback $ safely . onDeliveryReport hooks)
<> setCallback (errorCallback $ \e m -> safely $ onProducerError hooks e m)
where
safely :: IO () -> IO ()
safely = handleAny $ \_ -> pure ()