kafka-effectful-0.2.0.0: src/Kafka/Effectful/OpenTelemetry/Consumer/Interpreter.hs
-- The 'EffectHandler' type synonym in effectful-core expands to a
-- constraint that GHC's redundant-constraint check flags on the
-- handler's signature, even though the constraint is required for
-- 'interpret' to type-check. Suppress the warning at the file level.
{-# OPTIONS_GHC -Wno-redundant-constraints #-}
{- | OpenTelemetry-traced interpreter for the 'KafkaConsumer' effect.
Drop-in alternative to 'Kafka.Effectful.Consumer.Interpreter.runKafkaConsumer'
that opens a Consumer-kind span on every successful record return from
'pollMessage' \/ 'pollMessageBatch', rooted at the W3C trace context
extracted from the record\'s Kafka headers (or as a new root span when
no inbound context is present). Polls that return @Nothing@ on timeout
do not open a span, preserving the existing timeout-returns-@Nothing@
semantics.
Non-polling operations (offset commit, partition assignment, etc.) are
passed through unchanged.
The design parallels the upstream
@hs-opentelemetry-instrumentation-hw-kafka-client@\'s
@OpenTelemetry.Instrumentation.Kafka.pollMessage@.
@since 0.2.0.0
-}
module Kafka.Effectful.OpenTelemetry.Consumer.Interpreter (
-- * Interpreter
runKafkaConsumerTraced,
)
where
import Control.Monad (void)
import Data.ByteString (ByteString)
import Data.Foldable (for_)
import Data.Map.Strict qualified as Map
import Effectful (Eff, IOE, (:>))
import Effectful qualified
import Effectful.Dispatch.Dynamic (EffectHandler, interpret)
import Effectful.Error.Static (Error, throwError)
import Effectful.Exception (ExitCase (..))
import Effectful.Exception qualified as Exception
import Kafka.Consumer (RdKafkaRespErrT (..))
import Kafka.Consumer qualified as K
import Kafka.Consumer.ConsumerProperties (ConsumerProperties (cpProps))
import Kafka.Consumer.Subscription (Subscription)
import Kafka.Consumer.Types (ConsumerRecord (crTopic))
import Kafka.Effectful.Consumer.Effect (KafkaConsumer (..))
import Kafka.Effectful.OpenTelemetry.Propagation (
extractTraceContextFromRecord,
)
import Kafka.Effectful.OpenTelemetry.Semantic (
consumerRecordAttributes,
consumerSpanName,
)
import Kafka.Types (KafkaError (..))
import OpenTelemetry.Attributes.Attribute (toAttribute)
import OpenTelemetry.Attributes.Map (AttributeMap, insertAttributeByKey)
import OpenTelemetry.Context.ThreadLocal (attachContext, getContext)
import OpenTelemetry.SemanticConventions (messaging_kafka_consumer_group)
import OpenTelemetry.Trace.Core (
SpanArguments (kind),
SpanKind (Consumer),
Tracer,
addAttributesToSpanArguments,
defaultSpanArguments,
inSpan'',
)
{- | Run the 'KafkaConsumer' effect with OpenTelemetry tracing.
Identical in shape to 'Kafka.Effectful.Consumer.Interpreter.runKafkaConsumer',
plus an additional 'Tracer' argument. On every successful record return
from 'pollMessage' \/ 'pollMessageBatch' the interpreter:
* extracts a W3C trace context from the record\'s headers and
attaches it as the current thread\'s 'OpenTelemetry.Context.Context';
* opens a Consumer-kind span named @\"process \<topic\>\"@,
populated with the spec-aligned @messaging.*@ attribute set plus
the @messaging.kafka.consumer.group@ attribute (read from the
@group.id@ entry of the supplied 'ConsumerProperties').
Polls that time out (return @Nothing@) do not open a span. Per-record
errors in batch polls (@Left err@ entries) are kept in place in the
returned list and do not get spans either. The consumer handle is
acquired and released via 'Exception.generalBracket', exactly as
'runKafkaConsumer' does.
@since 0.2.0.0
-}
runKafkaConsumerTraced ::
(IOE :> es, Error KafkaError :> es) =>
Tracer ->
ConsumerProperties ->
Subscription ->
Eff (KafkaConsumer : es) a ->
Eff es a
runKafkaConsumerTraced tracer props sub action =
fst
<$> Exception.generalBracket
acquire
release
( \consumer ->
interpret (handleTracedConsumer tracer props consumer) action
)
where
acquire = do
result <- Effectful.liftIO $ K.newConsumer props sub
case result of
Left err -> throwError err
Right consumer -> pure consumer
release consumer = \case
ExitCaseSuccess _ -> do
mbErr <- Effectful.liftIO $ K.closeConsumer consumer
for_ mbErr throwError
ExitCaseException _ ->
Effectful.liftIO . void $ K.closeConsumer consumer
ExitCaseAbort ->
Effectful.liftIO . void $ K.closeConsumer consumer
handleTracedConsumer ::
(IOE :> es, Error KafkaError :> es) =>
Tracer ->
ConsumerProperties ->
K.KafkaConsumer ->
EffectHandler KafkaConsumer es
handleTracedConsumer tracer props consumer _env = \case
PollMessage timeout -> do
result <- Effectful.liftIO $ K.pollMessage consumer timeout
case result of
Left (KafkaResponseError RdKafkaRespErrTimedOut) -> pure Nothing
Left err -> throwError err
Right cr -> Just <$> withConsumerSpan tracer props cr (pure cr)
PollMessageBatch timeout batchSize -> do
results <-
Effectful.liftIO $
K.pollMessageBatch consumer timeout batchSize
traverse openSpanForResult results
where
openSpanForResult (Left err) = pure (Left err)
openSpanForResult (Right cr) =
Right <$> withConsumerSpan tracer props cr (pure cr)
CommitOffsetMessage oc cr -> throwOnJust $ K.commitOffsetMessage oc consumer cr
CommitAllOffsets oc -> throwOnJust $ K.commitAllOffsets oc consumer
CommitPartitionsOffsets oc tps -> throwOnJust $ K.commitPartitionsOffsets oc consumer tps
StoreOffsets tps -> throwOnJust $ K.storeOffsets consumer tps
StoreOffsetMessage cr -> throwOnJust $ K.storeOffsetMessage consumer cr
Assign tps -> throwOnJust $ K.assign consumer tps
PausePartitions parts ->
throwOnKafkaErr (K.pausePartitions consumer parts)
ResumePartitions parts ->
throwOnKafkaErr (K.resumePartitions consumer parts)
SeekPartitions tps timeout -> throwOnJust $ K.seekPartitions consumer tps timeout
Committed timeout parts -> throwOnLeft $ K.committed consumer timeout parts
Position parts -> throwOnLeft $ K.position consumer parts
Assignment -> throwOnLeft $ K.assignment consumer
Subscription -> throwOnLeft $ K.subscription consumer
AskConsumerHandle -> pure consumer
where
throwOnJust action' = do
mbErr <- Effectful.liftIO action'
for_ mbErr throwError
throwOnLeft action' = do
result <- Effectful.liftIO action'
case result of
Left err -> throwError err
Right a -> pure a
throwOnKafkaErr action' = do
err <- Effectful.liftIO action'
case err of
KafkaResponseError RdKafkaRespErrNoError -> pure ()
_ -> throwError err
{- | Open a Consumer-kind span around an action that processes a single
record.
Extracts the W3C trace context from the record\'s headers, attaches
it as the current thread context, then opens a span named
@\"process \<topic\>\"@ populated with the @messaging.*@ attribute
set (including @messaging.kafka.consumer.group@ when known).
-}
withConsumerSpan ::
(IOE :> es) =>
Tracer ->
ConsumerProperties ->
ConsumerRecord (Maybe ByteString) (Maybe ByteString) ->
Eff es a ->
Eff es a
withConsumerSpan tracer props cr action = do
inboundCtx <- Effectful.liftIO $ do
currentCtx <- getContext
extractTraceContextFromRecord cr currentCtx
void $ attachContext inboundCtx
inSpan'' tracer (consumerSpanName (crTopic cr)) spanArgs $ \_span -> action
where
spanArgs =
addAttributesToSpanArguments
(withConsumerGroup (consumerRecordAttributes cr))
defaultSpanArguments{kind = Consumer}
withConsumerGroup :: AttributeMap -> AttributeMap
withConsumerGroup attrs =
case Map.lookup "group.id" (cpProps props) of
Just groupId ->
insertAttributeByKey
messaging_kafka_consumer_group
(toAttribute groupId)
attrs
Nothing -> attrs