effectful-tracing-0.1.0.0: src/Effectful/Tracing/Instrumentation/Amqp.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Effectful.Tracing.Instrumentation.Amqp
-- Description : Tracing wrappers for the amqp (RabbitMQ) client.
-- Copyright : (c) The effectful-tracing contributors
-- License : BSD-3-Clause
-- Stability : experimental
--
-- Drop-in replacements for the core @amqp@ publish and consume calls that run
-- each inside a span recording the stable OpenTelemetry messaging semantic
-- conventions (see "Effectful.Tracing.SemConv"). The wrappers live in @'Eff' es@
-- and delegate to "Effectful.Tracing.Instrumentation.Messaging", so the span is
-- named after the operation and destination and is finalized even if the call
-- throws.
--
-- The producer side ('publishMsgTraced') writes the active span's
-- <https://www.w3.org/TR/trace-context/ W3C Trace Context> into the message's
-- AMQP headers, and the consumer side ('withProcessSpan') reads it back, so a
-- trace continues across the broker without any manual header plumbing.
--
-- Import this module qualified alongside @Network.AMQP@:
--
-- > import Network.AMQP (Channel, Ack (Ack), newMsg, msgBody)
-- > import Effectful.Tracing.Instrumentation.Amqp qualified as Amqp
-- >
-- > -- producer: publish a message, attaching the trace context to its headers
-- > placeOrder :: (IOE :> es, Tracer :> es) => Channel -> Eff es ()
-- > placeOrder chan = do
-- > _ <- Amqp.publishMsgTraced chan "orders" "orders.created" newMsg {msgBody = body}
-- > pure ()
-- >
-- > -- consumer: poll for a message, then process it under the producer's trace
-- > handleOrder :: (IOE :> es, Tracer :> es) => Channel -> Eff es ()
-- > handleOrder chan = do
-- > received <- Amqp.getMsgTraced chan Ack "orders"
-- > case received of
-- > Nothing -> pure ()
-- > Just (msg, env) -> Amqp.withProcessSpan msg env (process (msgBody msg))
--
-- The @messaging.destination.name@ is the exchange the call targets, falling
-- back to the routing key for the default (empty) exchange, since that is the
-- queue name. RabbitMQ does not expose a portable message size before send, so
-- @messaging.message.body.size@ is taken from the body you hand it.
module Effectful.Tracing.Instrumentation.Amqp
( -- * Producing
publishMsgTraced
-- * Consuming
, getMsgTraced
, withProcessSpan
-- * Reading headers
, messageHeaders
) where
import Control.Monad.IO.Class (liftIO)
import Data.ByteString.Lazy qualified as BL
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8)
import GHC.Stack (HasCallStack)
import Network.AMQP
( Ack
, Channel
, Envelope (envExchangeName, envRoutingKey)
, Message (msgBody, msgCorrelationID, msgHeaders, msgID)
, getMsg
, publishMsg
)
import Network.AMQP.Types (FieldTable (FieldTable), FieldValue (FVString))
import Effectful (Eff, IOE, (:>))
import Effectful.Tracing (Tracer)
import Effectful.Tracing.Instrumentation.Messaging
( MessagingOperation
( messagingBodySize
, messagingConversationId
, messagingDestination
, messagingMessageId
)
, MessagingOperationType (Process, Receive, Send)
, injectMessageHeaders
, messagingOperation
, withConsumerSpan
, withMessagingSpan
)
-- | 'Network.AMQP.publishMsg' wrapped in a traced @producer@-kind span. The
-- active span's trace context is merged into the message's headers before it is
-- sent, so a consumer using 'withProcessSpan' continues this trace.
publishMsgTraced
:: (HasCallStack, IOE :> es, Tracer :> es)
=> Channel
-> Text
-- ^ Exchange.
-> Text
-- ^ Routing key.
-> Message
-> Eff es (Maybe Int)
publishMsgTraced chan exchange routingKey msg =
withMessagingSpan (describePublish exchange routingKey msg) $ do
headers <- injectMessageHeaders
liftIO (publishMsg chan exchange routingKey (setTraceHeaders headers msg))
-- | 'Network.AMQP.getMsg' wrapped in a traced @consumer@-kind span for the
-- @receive@ operation. This traces the act of fetching from the queue; to also
-- trace processing under the producer's trace, pass the result to
-- 'withProcessSpan'.
getMsgTraced
:: (HasCallStack, IOE :> es, Tracer :> es)
=> Channel
-> Ack
-> Text
-- ^ Queue.
-> Eff es (Maybe (Message, Envelope))
getMsgTraced chan ack queue =
withMessagingSpan (describeReceive queue) (liftIO (getMsg chan ack queue))
-- | Process a received message inside a traced @consumer@-kind span for the
-- @process@ operation. The message's trace-context headers are read with
-- 'messageHeaders', so the span continues the producer's trace when they are
-- present and opens a new local root otherwise. Use this around the body of a
-- 'Network.AMQP.consumeMsgs' callback or a 'getMsgTraced' result.
withProcessSpan
:: (HasCallStack, Tracer :> es)
=> Message
-> Envelope
-> Eff es a
-> Eff es a
withProcessSpan msg env =
withConsumerSpan (messageHeaders msg) (describeProcess msg env)
-- | The text headers carried on a message, as @key\/value@ pairs. Only
-- string-valued AMQP headers are returned (trace-context headers are always
-- strings); other field types are dropped. This is the input
-- 'withProcessSpan' uses to continue the producer's trace.
messageHeaders :: Message -> [(Text, Text)]
messageHeaders msg =
case msgHeaders msg of
Nothing -> []
Just (FieldTable table) -> mapMaybe textHeader (Map.toList table)
where
textHeader (name, FVString bytes) = Just (name, decodeUtf8Lenient bytes)
textHeader _ = Nothing
-- | Merge trace-context headers into a message, overwriting any existing
-- entries with the same keys and preserving the rest.
setTraceHeaders :: [(Text, Text)] -> Message -> Message
setTraceHeaders [] msg = msg
setTraceHeaders headers msg =
msg {msgHeaders = Just (FieldTable (foldr insertHeader existing headers))}
where
existing = case msgHeaders msg of
Just (FieldTable table) -> table
Nothing -> Map.empty
insertHeader (name, value) = Map.insert name (FVString (encodeUtf8 value))
-- | Describe a publish: system @\"rabbitmq\"@, a @send@ operation, the
-- destination, and the message id, correlation id, and body size when present.
describePublish :: Text -> Text -> Message -> MessagingOperation
describePublish exchange routingKey msg =
(messagingOperation "rabbitmq" Send)
{ messagingDestination = Just (publishDestination exchange routingKey)
, messagingMessageId = msgID msg
, messagingConversationId = msgCorrelationID msg
, messagingBodySize = Just (bodySize msg)
}
-- | Describe a poll: system @\"rabbitmq\"@, a @receive@ operation, and the queue
-- as the destination.
describeReceive :: Text -> MessagingOperation
describeReceive queue =
(messagingOperation "rabbitmq" Receive) {messagingDestination = Just queue}
-- | Describe processing a delivered message: system @\"rabbitmq\"@, a @process@
-- operation, the destination from the delivery envelope, and the message id,
-- correlation id, and body size when present.
describeProcess :: Message -> Envelope -> MessagingOperation
describeProcess msg env =
(messagingOperation "rabbitmq" Process)
{ messagingDestination = Just (envDestination env)
, messagingMessageId = msgID msg
, messagingConversationId = msgCorrelationID msg
, messagingBodySize = Just (bodySize msg)
}
-- | The destination name for a publish: the exchange, or the routing key when
-- the exchange is empty (the default exchange routes directly to the queue).
publishDestination :: Text -> Text -> Text
publishDestination exchange routingKey
| T.null exchange = routingKey
| otherwise = exchange
-- | The destination name from a delivery envelope, by the same rule as
-- 'publishDestination'.
envDestination :: Envelope -> Text
envDestination env
| T.null (envExchangeName env) = envRoutingKey env
| otherwise = envExchangeName env
-- | The message body size in bytes.
bodySize :: Message -> Int
bodySize = fromIntegral . BL.length . msgBody