effectful-tracing-0.1.0.0: src/Effectful/Tracing/Instrumentation/Messaging.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Effectful.Tracing.Instrumentation.Messaging
-- Description : Framework-agnostic helpers for tracing message producers and consumers.
-- Copyright : (c) The effectful-tracing contributors
-- License : BSD-3-Clause
-- Stability : experimental
--
-- A small, dependency-free core for wrapping a publish or consume in a span that
-- records the stable OpenTelemetry messaging semantic conventions (see
-- "Effectful.Tracing.SemConv"). It knows nothing about any particular broker:
-- you describe the call with a 'MessagingOperation' and run the action inside
-- 'withMessagingSpan'. The operation type ('MessagingOperationType') selects the
-- span kind, so @send@ \/ @create@ become @producer@ spans and @receive@ \/
-- @process@ become @consumer@ spans, matching the OpenTelemetry model.
--
-- Distributed traces cross a broker by carrying the
-- <https://www.w3.org/TR/trace-context/ W3C Trace Context> in message headers:
-- the producer attaches the headers from 'injectMessageHeaders', and the
-- consumer hands the received headers to 'withConsumerSpan' (or extracts them
-- with 'extractMessageHeaders') so its span continues the producer's trace. The
-- headers are plain text key\/value pairs, the portable shape across Kafka,
-- RabbitMQ, SQS, and the like.
--
-- > -- producer: open a send span and attach the trace context to the message
-- > publishOrder :: (IOE :> es, Tracer :> es) => Order -> Eff es ()
-- > publishOrder order =
-- > withMessagingSpan (messagingOperation "kafka" Send) { messagingDestination = Just "orders" } $ do
-- > headers <- injectMessageHeaders
-- > liftIO (produce "orders" headers (encode order))
-- >
-- > -- consumer: continue the producer's trace under a process span
-- > handleOrder :: (IOE :> es, Tracer :> es) => Message -> Eff es ()
-- > handleOrder msg =
-- > withConsumerSpan
-- > (messageHeaders msg)
-- > (messagingOperation "kafka" Process) { messagingDestination = Just "orders" }
-- > (liftIO (process (messageBody msg)))
--
-- The span is named following the convention @{operation} {destination}@ (for
-- example @\"send orders\"@), preferring 'messagingOperationName' then the
-- operation type for the leading word, and 'messagingDestinationTemplate' then
-- 'messagingDestination' for the trailing word, so the name stays low
-- cardinality.
module Effectful.Tracing.Instrumentation.Messaging
( -- * Describing an operation
MessagingOperation (..)
, MessagingOperationType (..)
, messagingOperation
-- * Tracing an operation
, withMessagingSpan
, withConsumerSpan
-- * Propagating context through message headers
, injectMessageHeaders
, extractMessageHeaders
-- * Helpers
, messagingSpanName
, messagingSpanKind
, messagingAttributes
, operationTypeText
) where
import Control.Applicative ((<|>))
import Data.CaseInsensitive qualified as CI
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8)
import GHC.Stack (HasCallStack)
import Effectful (Eff, (:>))
import Effectful.Tracing
( SpanArguments (attributes, kind)
, SpanContext
, SpanKind (Client, Consumer, Producer)
, Tracer
, defaultSpanArguments
, extractContext
, injectContext
, withRemoteParent
, withSpan'
, (.=)
)
import Effectful.Tracing.Attribute (Attribute)
import Effectful.Tracing.SemConv qualified as SemConv
-- | The kind of messaging operation, following the OpenTelemetry
-- @messaging.operation.type@ values. The constructor both fills that attribute
-- (see 'operationTypeText') and selects the span kind (see 'messagingSpanKind').
data MessagingOperationType
= -- | A message is created but not yet sent (a @producer@ span).
Create
| -- | One or more messages are handed to the broker (a @producer@ span). This
-- covers what some systems call \"publish\".
Send
| -- | One or more messages are requested from the broker (a @consumer@ span).
Receive
| -- | One or more received messages are processed (a @consumer@ span).
Process
| -- | One or more messages are settled, for example acknowledged or rejected
-- (a @client@ span).
Settle
deriving (Eq, Show)
-- | A broker-agnostic description of a messaging call, used to populate the
-- @messaging.*@ attributes (see "Effectful.Tracing.SemConv") and the span name.
-- Build it with 'messagingOperation' and fill in the fields you know; every
-- optional field left as 'Nothing' is simply not recorded.
data MessagingOperation = MessagingOperation
{ messagingSystem :: !Text
-- ^ @messaging.system@: the messaging system, for example @\"kafka\"@.
-- Required.
, messagingOperationType :: !MessagingOperationType
-- ^ @messaging.operation.type@: the operation category, which also selects the
-- span kind. Required.
, messagingOperationName :: !(Maybe Text)
-- ^ @messaging.operation.name@: the system-specific operation name, for
-- example @\"publish\"@ or @\"ack\"@. Used as the leading word of the span
-- name; falls back to the operation type when unset.
, messagingDestination :: !(Maybe Text)
-- ^ @messaging.destination.name@: the destination the call acts on, for
-- example a topic or queue name.
, messagingDestinationTemplate :: !(Maybe Text)
-- ^ @messaging.destination.template@: a low-cardinality template the
-- destination is derived from. Preferred over 'messagingDestination' in the
-- span name when destinations are dynamic.
, messagingMessageId :: !(Maybe Text)
-- ^ @messaging.message.id@: the broker-assigned identifier of a single
-- message.
, messagingConversationId :: !(Maybe Text)
-- ^ @messaging.message.conversation_id@: the conversation \/ correlation
-- identifier tying related messages together.
, messagingBodySize :: !(Maybe Int)
-- ^ @messaging.message.body.size@: the size of the message body in bytes.
, messagingBatchCount :: !(Maybe Int)
-- ^ @messaging.batch.message_count@: the number of messages in a batch
-- operation.
}
deriving (Eq, Show)
-- | A 't:MessagingOperation' for the given @messaging.system@ and operation
-- type with every optional field unset, ready for record-update syntax to fill
-- in what you know.
--
-- > (messagingOperation "rabbitmq" Send) { messagingDestination = Just "orders" }
messagingOperation :: Text -> MessagingOperationType -> MessagingOperation
messagingOperation system operationType =
MessagingOperation
{ messagingSystem = system
, messagingOperationType = operationType
, messagingOperationName = Nothing
, messagingDestination = Nothing
, messagingDestinationTemplate = Nothing
, messagingMessageId = Nothing
, messagingConversationId = Nothing
, messagingBodySize = Nothing
, messagingBatchCount = Nothing
}
-- | Run a messaging action inside a span named by 'messagingSpanName', of the
-- kind 'messagingSpanKind' picks for the operation type, and annotated with
-- 'messagingAttributes'. The span is finalized (with its end time, and
-- 'Effectful.Tracing.Error' status if the action throws) by the shared span
-- lifecycle when the action returns or unwinds.
--
-- This opens a fresh span as a child of the current one. On the consumer side,
-- use 'withConsumerSpan' instead to continue the producer's remote trace.
withMessagingSpan
:: (HasCallStack, Tracer :> es)
=> MessagingOperation
-> Eff es a
-> Eff es a
withMessagingSpan op =
withSpan'
(messagingSpanName op)
defaultSpanArguments
{ kind = messagingSpanKind (messagingOperationType op)
, attributes = messagingAttributes op
}
-- | Run a consumer action inside a 'withMessagingSpan' that continues the
-- producer's trace. The given message headers are parsed with
-- 'extractMessageHeaders'; when they carry a valid context the span becomes a
-- child of that remote parent (via 'Effectful.Tracing.withRemoteParent'),
-- otherwise it opens a new local root. Pass a @receive@ or @process@ operation.
withConsumerSpan
:: (HasCallStack, Tracer :> es)
=> [(Text, Text)]
-- ^ Headers from the received message.
-> MessagingOperation
-> Eff es a
-> Eff es a
withConsumerSpan headers op action =
case extractMessageHeaders headers of
Just parent -> withRemoteParent parent (withMessagingSpan op action)
Nothing -> withMessagingSpan op action
-- | Serialize the active span's context as message headers for an outbound
-- message, as plain text @traceparent@ (and @tracestate@, if non-empty)
-- key\/value pairs. Attach these to the message you publish so the consumer can
-- continue the trace. Returns @[]@ when there is no active span, so it composes
-- with a base header list unconditionally.
injectMessageHeaders :: (Tracer :> es) => Eff es [(Text, Text)]
injectMessageHeaders = map toTextHeader <$> injectContext
where
toTextHeader (name, value) =
(decodeUtf8Lenient (CI.original name), decodeUtf8Lenient value)
-- | Parse the trace context out of a received message's headers into a remote
-- 't:SpanContext', the consumer-side counterpart of 'injectMessageHeaders'.
-- Returns 'Nothing' when no valid @traceparent@ is present. Pair with
-- 'Effectful.Tracing.withRemoteParent', or use 'withConsumerSpan', which does
-- both. Header lookup is case-insensitive.
extractMessageHeaders :: [(Text, Text)] -> Maybe SpanContext
extractMessageHeaders = extractContext . map fromTextHeader
where
fromTextHeader (name, value) = (CI.mk (encodeUtf8 name), encodeUtf8 value)
-- | The span name for a messaging operation, following the OpenTelemetry
-- convention: @{operation} {destination}@ (for example @\"send orders\"@). The
-- leading word prefers 'messagingOperationName', falling back to the operation
-- type; the trailing word prefers 'messagingDestinationTemplate', then
-- 'messagingDestination', and is omitted when neither is set. This keeps the
-- name low cardinality.
messagingSpanName :: MessagingOperation -> Text
messagingSpanName op =
case messagingDestinationTemplate op <|> messagingDestination op of
Just destination -> label <> " " <> destination
Nothing -> label
where
label = fromMaybe (operationTypeText (messagingOperationType op)) (messagingOperationName op)
-- | The span kind for an operation type: @producer@ for 'Create' and 'Send',
-- @consumer@ for 'Receive' and 'Process', and @client@ for 'Settle'.
messagingSpanKind :: MessagingOperationType -> SpanKind
messagingSpanKind = \case
Create -> Producer
Send -> Producer
Receive -> Consumer
Process -> Consumer
Settle -> Client
-- | The @messaging.operation.type@ value for an operation type: the lowercase
-- name, for example @\"send\"@ for 'Send'.
operationTypeText :: MessagingOperationType -> Text
operationTypeText = \case
Create -> "create"
Send -> "send"
Receive -> "receive"
Process -> "process"
Settle -> "settle"
-- | The @messaging.*@ attributes for an operation: @messaging.system@ and
-- @messaging.operation.type@ always, plus @messaging.operation.name@,
-- @messaging.destination.name@, @messaging.destination.template@,
-- @messaging.message.id@, @messaging.message.conversation_id@,
-- @messaging.message.body.size@, and @messaging.batch.message_count@ for
-- whichever optional fields are set.
messagingAttributes :: MessagingOperation -> [Attribute]
messagingAttributes op =
[ SemConv.messagingSystem .= messagingSystem op
, SemConv.messagingOperationType .= operationTypeText (messagingOperationType op)
]
<> mapMaybe
optionalText
[ (SemConv.messagingOperationName, messagingOperationName op)
, (SemConv.messagingDestinationName, messagingDestination op)
, (SemConv.messagingDestinationTemplate, messagingDestinationTemplate op)
, (SemConv.messagingMessageId, messagingMessageId op)
, (SemConv.messagingMessageConversationId, messagingConversationId op)
]
<> mapMaybe
optionalInt
[ (SemConv.messagingMessageBodySize, messagingBodySize op)
, (SemConv.messagingBatchMessageCount, messagingBatchCount op)
]
where
optionalText (key, value) = (key .=) <$> value
optionalInt (key, value) = (key .=) <$> value