packages feed

pgmq-effectful-0.2.0.0: src/Pgmq/Effectful/Interpreter/Traced.hs

-- | OpenTelemetry-instrumented interpreter for the Pgmq effect.
--
-- This module provides a traced version of the Pgmq interpreter that
-- creates OpenTelemetry spans for all PGMQ operations.
--
-- == OpenTelemetry Semantic Conventions
--
-- Spans emitted by this interpreter follow OpenTelemetry
-- [Semantic Conventions v1.24](https://github.com/open-telemetry/semantic-conventions/tree/v1.24.0)
-- for messaging clients and database clients.
--
-- For every operation the interpreter emits:
--
-- * @db.system = "postgresql"@
-- * @db.operation = "pgmq.<fn>"@ (for example @"pgmq.send"@, @"pgmq.read"@,
--   @"pgmq.archive"@).
--
-- For /messaging/ operations (the publish and receive families) the
-- interpreter additionally emits:
--
-- * @messaging.system = "pgmq"@
-- * @messaging.operation@ — one of @"publish"@ (every @send*@ variant,
--   including topic sends) or @"receive"@ (every @read*@ and @pop@
--   variant). The @"process"@ operation is intentionally not emitted
--   here — it belongs to the consumer, which should open its own
--   @process@ span around application-level handling of a received
--   message (see 'Pgmq.Effectful.Traced.readMessageWithContext').
-- * @messaging.destination.name@ — the queue name, or for topic sends
--   the routing key (which is the logical destination for pgmq topics).
--
-- == Span Names
--
-- Span names follow the v1.24 @"<operation> <destination>"@ form:
--
-- * Messaging spans: @"publish my-queue"@, @"receive my-queue"@.
-- * Lifecycle and observability spans: @"pgmq.archive my-queue"@,
--   @"pgmq.set_vt my-queue"@, @"pgmq.list_queues"@ (no destination).
--
-- == Span Kinds
--
-- * 'OTel.Producer': every @send*@ / @send_topic*@ variant.
-- * 'OTel.Consumer': every @read*@ variant and @pop@.
-- * 'OTel.Internal': lifecycle (create, drop, archive, delete, set_vt,
--   purge, bind_topic, …) and observability (metrics, list_queues,
--   list_topic_bindings, validate_*).
--
-- == Usage
--
-- @
-- import OpenTelemetry.Trace qualified as OTel
-- import Pgmq.Effectful.Interpreter.Traced
--
-- main :: IO ()
-- main = do
--   tracerProvider <- OTel.getGlobalTracerProvider
--   let tracer = OTel.makeTracer tracerProvider "my-app" OTel.tracerOptions
--   pool <- ...
--
--   runEff
--     . runError @PgmqRuntimeError
--     . runPgmqTraced pool tracer
--     $ do
--       createQueue "my-queue"
--       sendMessage $ SendMessage "my-queue" (MessageBody "hello") Nothing
-- @
module Pgmq.Effectful.Interpreter.Traced
  ( -- * Interpreters
    runPgmqTraced,
    runPgmqTracedWith,

    -- * Configuration
    TracingConfig (..),
    defaultTracingConfig,
  )
where

import Control.Monad (when)
import Data.Int (Int64)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, IOE, (:>))
import Effectful qualified
import Effectful.Dispatch.Dynamic (interpret)
import Effectful.Error.Static (Error, throwError)
import Hasql.Errors qualified as HasqlErrors
import Hasql.Pool (Pool)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified
import OpenTelemetry.Attributes.Map (AttributeMap, insertByKey)
import OpenTelemetry.Trace.Core qualified as OTel
import Pgmq.Effectful.Effect (Pgmq (..))
import Pgmq.Effectful.Interpreter
  ( PgmqRuntimeError (..),
    fromUsageError,
  )
import Pgmq.Effectful.Telemetry
import Pgmq.Hasql.Sessions qualified as Sessions
import Pgmq.Hasql.Statements.Types qualified as Types
import Pgmq.Types (MessageId (..), QueueName, RoutingKey, queueNameToText, routingKeyToText)

-- | Configuration for OpenTelemetry tracing.
data TracingConfig = TracingConfig
  { -- | The OpenTelemetry tracer to use
    tracer :: !OTel.Tracer,
    -- | Whether to record exceptions on spans
    recordExceptions :: !Bool,
    -- | Whether to include message bodies in spans (may contain PII)
    includeMessageBodies :: !Bool
  }

-- | Create a default tracing configuration.
defaultTracingConfig :: OTel.Tracer -> TracingConfig
defaultTracingConfig t =
  TracingConfig
    { tracer = t,
      recordExceptions = True,
      includeMessageBodies = False
    }

-- | Describes how a 'Pgmq' operation maps onto OpenTelemetry semantic
-- conventions v1.24.
data OpInfo = OpInfo
  { -- | The pgmq SQL function being invoked (@"pgmq.send"@ etc.).
    opDbFunction :: !Text,
    -- | The v1.24 @messaging.operation@ verb, if any. One of
    -- @"publish"@ or @"receive"@; @Nothing@ for lifecycle/observability
    -- operations.
    opMessagingKind :: !(Maybe Text),
    -- | The span kind.
    opSpanKind :: !OTel.SpanKind,
    -- | The logical destination (queue name, or the routing key for
    -- topic sends). @Nothing@ for operations without a per-queue scope
    -- (for example @list_queues@, @metrics_all@).
    opDestination :: !(Maybe Text),
    -- | A pre-known message id (for per-message operations like delete,
    -- archive, set_vt).
    opMessageId :: !(Maybe MessageId),
    -- | Batch size, for batch operations.
    opBatchCount :: !(Maybe Int)
  }

-- | An 'OpInfo' with no messaging verb, no destination, no message id,
-- and no batch count.
defaultOpInfo :: Text -> OTel.SpanKind -> OpInfo
defaultOpInfo fn kind =
  OpInfo
    { opDbFunction = fn,
      opMessagingKind = Nothing,
      opSpanKind = kind,
      opDestination = Nothing,
      opMessageId = Nothing,
      opBatchCount = Nothing
    }

-- | Run the Pgmq effect with OpenTelemetry instrumentation.
runPgmqTraced ::
  (IOE :> es, Error PgmqRuntimeError :> es) =>
  Pool ->
  OTel.Tracer ->
  Eff (Pgmq : es) a ->
  Eff es a
runPgmqTraced pool t = runPgmqTracedWith pool (defaultTracingConfig t)

-- | Run the Pgmq effect with custom tracing configuration.
runPgmqTracedWith ::
  (IOE :> es, Error PgmqRuntimeError :> es) =>
  Pool ->
  TracingConfig ->
  Eff (Pgmq : es) a ->
  Eff es a
runPgmqTracedWith pool config = interpret $ \_ -> \case
  -- Queue Management (Internal spans)
  CreateQueue q ->
    withTracedOp config pool (queueOp "pgmq.create" OTel.Internal q) $
      Sessions.createQueue q
  DropQueue q ->
    withTracedOp config pool (queueOp "pgmq.drop_queue" OTel.Internal q) $
      Sessions.dropQueue q
  CreatePartitionedQueue pq@(Types.CreatePartitionedQueue qn _ _) ->
    withTracedOp config pool (queueOp "pgmq.create_partitioned" OTel.Internal qn) $
      Sessions.createPartitionedQueue pq
  CreateUnloggedQueue q ->
    withTracedOp config pool (queueOp "pgmq.create_unlogged" OTel.Internal q) $
      Sessions.createUnloggedQueue q
  DetachArchive _q ->
    pure ()
  EnableNotifyInsert cfg@(Types.EnableNotifyInsert qn _) ->
    withTracedOp config pool (queueOp "pgmq.enable_notify_insert" OTel.Internal qn) $
      Sessions.enableNotifyInsert cfg
  DisableNotifyInsert q ->
    withTracedOp config pool (queueOp "pgmq.disable_notify_insert" OTel.Internal q) $
      Sessions.disableNotifyInsert q
  CreateFifoIndex q ->
    withTracedOp config pool (queueOp "pgmq.create_fifo_index" OTel.Internal q) $
      Sessions.createFifoIndex q
  CreateFifoIndexesAll ->
    withTracedOp config pool (defaultOpInfo "pgmq.create_fifo_indexes_all" OTel.Internal) $
      Sessions.createFifoIndexesAll
  -- Message Operations - Send (Producer spans, messaging.operation=publish)
  SendMessage msg@(Types.SendMessage qn _ _) ->
    withTracedOp config pool (publishOp "pgmq.send" qn) $
      Sessions.sendMessage msg
  SendMessageForLater msg@(Types.SendMessageForLater qn _ _) ->
    withTracedOp config pool (publishOp "pgmq.send" qn) $
      Sessions.sendMessageForLater msg
  BatchSendMessage msg@(Types.BatchSendMessage qn bodies _) ->
    withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $
      Sessions.batchSendMessage msg
  BatchSendMessageForLater msg@(Types.BatchSendMessageForLater qn bodies _) ->
    withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $
      Sessions.batchSendMessageForLater msg
  SendMessageWithHeaders msg@(Types.SendMessageWithHeaders qn _ _ _) ->
    withTracedOp config pool (publishOp "pgmq.send" qn) $
      Sessions.sendMessageWithHeaders msg
  SendMessageWithHeadersForLater msg@(Types.SendMessageWithHeadersForLater qn _ _ _) ->
    withTracedOp config pool (publishOp "pgmq.send" qn) $
      Sessions.sendMessageWithHeadersForLater msg
  BatchSendMessageWithHeaders msg@(Types.BatchSendMessageWithHeaders qn bodies _ _) ->
    withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $
      Sessions.batchSendMessageWithHeaders msg
  BatchSendMessageWithHeadersForLater msg@(Types.BatchSendMessageWithHeadersForLater qn bodies _ _) ->
    withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $
      Sessions.batchSendMessageWithHeadersForLater msg
  -- Message Operations - Read (Consumer spans, messaging.operation=receive)
  ReadMessage query@(Types.ReadMessage qn _ _ _) ->
    withTracedOp config pool (receiveOp "pgmq.read" qn) $
      Sessions.readMessage query
  ReadWithPoll query@(Types.ReadWithPollMessage qn _ _ _ _ _) ->
    withTracedOp config pool (receiveOp "pgmq.read_with_poll" qn) $
      Sessions.readWithPoll query
  Pop query@(Types.PopMessage qn _) ->
    withTracedOp config pool (receiveOp "pgmq.pop" qn) $
      Sessions.pop query
  -- FIFO Read (Consumer spans)
  ReadGrouped query@(Types.ReadGrouped qn _ _) ->
    withTracedOp config pool (receiveOp "pgmq.read_grouped" qn) $
      Sessions.readGrouped query
  ReadGroupedWithPoll query@(Types.ReadGroupedWithPoll qn _ _ _ _) ->
    withTracedOp config pool (receiveOp "pgmq.read_grouped_with_poll" qn) $
      Sessions.readGroupedWithPoll query
  -- Round-robin FIFO Read (Consumer spans)
  ReadGroupedRoundRobin query@(Types.ReadGrouped qn _ _) ->
    withTracedOp config pool (receiveOp "pgmq.read_grouped_rr" qn) $
      Sessions.readGroupedRoundRobin query
  ReadGroupedRoundRobinWithPoll query@(Types.ReadGroupedWithPoll qn _ _ _ _) ->
    withTracedOp config pool (receiveOp "pgmq.read_grouped_rr_with_poll" qn) $
      Sessions.readGroupedRoundRobinWithPoll query
  -- Message Lifecycle (Internal spans)
  DeleteMessage query@(Types.MessageQuery qn msgId) ->
    withTracedOp config pool ((queueOp "pgmq.delete" OTel.Internal qn) {opMessageId = Just msgId}) $
      Sessions.deleteMessage query
  BatchDeleteMessages query@(Types.BatchMessageQuery qn msgIds) ->
    withTracedOp config pool ((queueOp "pgmq.delete" OTel.Internal qn) {opBatchCount = Just (length msgIds)}) $
      Sessions.batchDeleteMessages query
  ArchiveMessage query@(Types.MessageQuery qn msgId) ->
    withTracedOp config pool ((queueOp "pgmq.archive" OTel.Internal qn) {opMessageId = Just msgId}) $
      Sessions.archiveMessage query
  BatchArchiveMessages query@(Types.BatchMessageQuery qn msgIds) ->
    withTracedOp config pool ((queueOp "pgmq.archive" OTel.Internal qn) {opBatchCount = Just (length msgIds)}) $
      Sessions.batchArchiveMessages query
  DeleteAllMessagesFromQueue q ->
    withTracedOp config pool (queueOp "pgmq.purge_queue" OTel.Internal q) $
      Sessions.deleteAllMessagesFromQueue q
  ChangeVisibilityTimeout query@(Types.VisibilityTimeoutQuery qn msgId _) ->
    withTracedOp config pool ((queueOp "pgmq.set_vt" OTel.Internal qn) {opMessageId = Just msgId}) $
      Sessions.changeVisibilityTimeout query
  BatchChangeVisibilityTimeout query@(Types.BatchVisibilityTimeoutQuery qn msgIds _) ->
    withTracedOp config pool ((queueOp "pgmq.set_vt" OTel.Internal qn) {opBatchCount = Just (length msgIds)}) $
      Sessions.batchChangeVisibilityTimeout query
  -- Timestamp-based VT (pgmq 1.10.0+)
  SetVisibilityTimeoutAt query@(Types.VisibilityTimeoutAtQuery qn msgId _) ->
    withTracedOp config pool ((queueOp "pgmq.set_vt" OTel.Internal qn) {opMessageId = Just msgId}) $
      Sessions.setVisibilityTimeoutAt query
  BatchSetVisibilityTimeoutAt query@(Types.BatchVisibilityTimeoutAtQuery qn msgIds _) ->
    withTracedOp config pool ((queueOp "pgmq.set_vt" OTel.Internal qn) {opBatchCount = Just (length msgIds)}) $
      Sessions.batchSetVisibilityTimeoutAt query
  -- Topic Management (Internal spans, pgmq 1.11.0+)
  BindTopic params@(Types.BindTopic _ qn) ->
    withTracedOp config pool (queueOp "pgmq.bind_topic" OTel.Internal qn) $
      Sessions.bindTopic params
  UnbindTopic params@(Types.UnbindTopic _ qn) ->
    withTracedOp config pool (queueOp "pgmq.unbind_topic" OTel.Internal qn) $
      Sessions.unbindTopic params
  ValidateRoutingKey key ->
    withTracedOp config pool (defaultOpInfo "pgmq.validate_routing_key" OTel.Internal) $
      Sessions.validateRoutingKey key
  ValidateTopicPattern pat ->
    withTracedOp config pool (defaultOpInfo "pgmq.validate_topic_pattern" OTel.Internal) $
      Sessions.validateTopicPattern pat
  TestRouting key ->
    withTracedOp config pool (defaultOpInfo "pgmq.test_routing" OTel.Internal) $
      Sessions.testRouting key
  ListTopicBindings ->
    withTracedOp config pool (defaultOpInfo "pgmq.list_topic_bindings" OTel.Internal) $
      Sessions.listTopicBindings
  ListTopicBindingsForQueue q ->
    withTracedOp config pool (queueOp "pgmq.list_topic_bindings" OTel.Internal q) $
      Sessions.listTopicBindingsForQueue q
  -- Topic Sending (Producer spans, pgmq 1.11.0+)
  SendTopic msg@(Types.SendTopic rk _ _) ->
    withTracedOp config pool (publishTopicOp "pgmq.send_topic" rk) $
      Sessions.sendTopic msg
  SendTopicWithHeaders msg@(Types.SendTopicWithHeaders rk _ _ _) ->
    withTracedOp config pool (publishTopicOp "pgmq.send_topic" rk) $
      Sessions.sendTopicWithHeaders msg
  BatchSendTopic msg@(Types.BatchSendTopic rk bodies _) ->
    withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $
      Sessions.batchSendTopic msg
  BatchSendTopicForLater msg@(Types.BatchSendTopicForLater rk bodies _) ->
    withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $
      Sessions.batchSendTopicForLater msg
  BatchSendTopicWithHeaders msg@(Types.BatchSendTopicWithHeaders rk bodies _ _) ->
    withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $
      Sessions.batchSendTopicWithHeaders msg
  BatchSendTopicWithHeadersForLater msg@(Types.BatchSendTopicWithHeadersForLater rk bodies _ _) ->
    withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $
      Sessions.batchSendTopicWithHeadersForLater msg
  -- Notification Management (Internal spans, pgmq 1.11.0+)
  ListNotifyInsertThrottles ->
    withTracedOp config pool (defaultOpInfo "pgmq.list_notify_insert_throttles" OTel.Internal) $
      Sessions.listNotifyInsertThrottles
  UpdateNotifyInsert params@(Types.UpdateNotifyInsert qn _) ->
    withTracedOp config pool (queueOp "pgmq.update_notify_insert" OTel.Internal qn) $
      Sessions.updateNotifyInsert params
  -- Queue Observability (Internal spans)
  ListQueues ->
    withTracedOp config pool (defaultOpInfo "pgmq.list_queues" OTel.Internal) $
      Sessions.listQueues
  QueueMetrics q ->
    withTracedOp config pool (queueOp "pgmq.metrics" OTel.Internal q) $
      Sessions.queueMetrics q
  AllQueueMetrics ->
    withTracedOp config pool (defaultOpInfo "pgmq.metrics_all" OTel.Internal) $
      Sessions.allQueueMetrics

-- ---------------------------------------------------------------------
-- OpInfo constructors
-- ---------------------------------------------------------------------

-- | 'OpInfo' for a non-messaging operation scoped to a single queue.
queueOp :: Text -> OTel.SpanKind -> QueueName -> OpInfo
queueOp fn kind qn = (defaultOpInfo fn kind) {opDestination = Just (queueNameToText qn)}

-- | 'OpInfo' for a @publish@ (send) on a queue.
publishOp :: Text -> QueueName -> OpInfo
publishOp fn qn =
  (queueOp fn OTel.Producer qn) {opMessagingKind = Just "publish"}

-- | 'OpInfo' for a batch @publish@ on a queue.
publishBatchOp :: Text -> QueueName -> Int -> OpInfo
publishBatchOp fn qn n = (publishOp fn qn) {opBatchCount = Just n}

-- | 'OpInfo' for a topic send (publish to a routing key).
publishTopicOp :: Text -> RoutingKey -> OpInfo
publishTopicOp fn rk =
  (defaultOpInfo fn OTel.Producer)
    { opMessagingKind = Just "publish",
      opDestination = Just (routingKeyToText rk)
    }

-- | 'OpInfo' for a batch topic send.
publishTopicBatchOp :: Text -> RoutingKey -> Int -> OpInfo
publishTopicBatchOp fn rk n = (publishTopicOp fn rk) {opBatchCount = Just n}

-- | 'OpInfo' for a @receive@ (read, pop) on a queue.
receiveOp :: Text -> QueueName -> OpInfo
receiveOp fn qn =
  (queueOp fn OTel.Consumer qn) {opMessagingKind = Just "receive"}

-- ---------------------------------------------------------------------
-- Span assembly
-- ---------------------------------------------------------------------

-- | Span name: @"<messaging.operation> <destination>"@ for messaging
-- spans, else @"<db.operation> <destination>"@; if there is no
-- destination, the operation name alone.
spanNameFor :: OpInfo -> Text
spanNameFor info =
  let op = case info.opMessagingKind of
        Just mk -> mk
        Nothing -> info.opDbFunction
   in case info.opDestination of
        Just d -> op <> " " <> d
        Nothing -> op

operationAttributes :: OpInfo -> AttributeMap
operationAttributes info =
  let base =
        insertByKey db_system ("postgresql" :: Text)
          . insertByKey db_operation info.opDbFunction
      withMsgKind = case info.opMessagingKind of
        Just mk ->
          insertByKey messaging_system ("pgmq" :: Text)
            . insertByKey messaging_operation mk
        Nothing -> id
      withDest = case info.opDestination of
        Just d -> insertByKey messaging_destination_name d
        Nothing -> id
      withMsgId = case info.opMessageId of
        Just (MessageId mid) ->
          insertByKey messaging_message_id (T.pack (show mid))
        Nothing -> id
      withCount = case info.opBatchCount of
        Just n -> insertByKey messaging_batch_messageCount (fromIntegral n :: Int64)
        Nothing -> id
   in (withMsgId . withCount . withDest . withMsgKind . base) mempty

withTracedOp ::
  (IOE :> es, Error PgmqRuntimeError :> es) =>
  TracingConfig ->
  Pool ->
  OpInfo ->
  Hasql.Session.Session a ->
  Eff es a
withTracedOp config pool info session = do
  let args =
        OTel.addAttributesToSpanArguments
          (operationAttributes info)
          OTel.defaultSpanArguments {OTel.kind = info.opSpanKind}
  result <-
    Effectful.liftIO $
      OTel.inSpan' config.tracer (spanNameFor info) args $ \s ->
        runSessionIO config s pool session
  throwOnLeft result

-- ---------------------------------------------------------------------
-- Session runner and error recording
-- ---------------------------------------------------------------------

-- | Rethrow a runtime error via the 'Error' effect when present.
throwOnLeft ::
  (Error PgmqRuntimeError :> es) =>
  Either PgmqRuntimeError a ->
  Eff es a
throwOnLeft = \case
  Left err -> throwError err
  Right a -> pure a

runSessionIO ::
  TracingConfig ->
  OTel.Span ->
  Pool ->
  Hasql.Session.Session a ->
  IO (Either PgmqRuntimeError a)
runSessionIO config s pool session = do
  result <- Pool.use pool session
  case result of
    Left err -> do
      let runtimeErr = fromUsageError err
      when config.recordExceptions $
        OTel.recordException s mempty Nothing err
      OTel.setStatus s (OTel.Error (errorStatusDescription runtimeErr))
      pure $ Left runtimeErr
    Right a -> do
      OTel.setStatus s OTel.Ok
      pure $ Right a

-- | Short, non-PII span-status description derived from a
-- 'PgmqRuntimeError'. The detail lives inside the 'recordException'
-- event; the span status description is a coarse label that backends
-- can group on without risking query text or credentials leaking into
-- dashboards.
errorStatusDescription :: PgmqRuntimeError -> Text
errorStatusDescription = \case
  PgmqAcquisitionTimeout -> "pool.acquisition_timeout"
  PgmqConnectionError e -> "pool.connection." <> connectionLabel e
  PgmqSessionError e -> "pool.session." <> sessionLabel e
  where
    connectionLabel :: HasqlErrors.ConnectionError -> Text
    connectionLabel = \case
      HasqlErrors.NetworkingConnectionError _ -> "networking"
      HasqlErrors.AuthenticationConnectionError _ -> "authentication"
      HasqlErrors.CompatibilityConnectionError _ -> "compatibility"
      HasqlErrors.OtherConnectionError _ -> "other"
    sessionLabel :: HasqlErrors.SessionError -> Text
    sessionLabel = \case
      HasqlErrors.ConnectionSessionError _ -> "connection"
      HasqlErrors.StatementSessionError {} -> "statement"
      HasqlErrors.ScriptSessionError {} -> "script"
      HasqlErrors.MissingTypesSessionError _ -> "missing_types"
      HasqlErrors.DriverSessionError _ -> "driver"