pgmq-effectful 0.1.3.0 → 0.2.0.0
raw patch · 12 files changed
+1235/−377 lines, 12 filesdep +case-insensitivedep +ephemeral-pgdep +hs-opentelemetry-exporter-in-memorydep ~pgmq-coredep ~pgmq-hasqlPVP ok
version bump matches the API change (PVP)
Dependencies added: case-insensitive, ephemeral-pg, hs-opentelemetry-exporter-in-memory, hs-opentelemetry-sdk, hs-opentelemetry-semantic-conventions, http-types, pgmq-effectful, pgmq-migration, random, tasty, tasty-hunit
Dependency ranges changed: pgmq-core, pgmq-hasql
API changes (from Hackage documentation)
- Pgmq.Effectful.Telemetry: dbOperationName :: Text
- Pgmq.Effectful.Telemetry: dbSystem :: Text
- Pgmq.Effectful.Telemetry: messagingBatchMessageCount :: Text
- Pgmq.Effectful.Telemetry: messagingDestinationName :: Text
- Pgmq.Effectful.Telemetry: messagingMessageId :: Text
- Pgmq.Effectful.Telemetry: messagingOperationType :: Text
- Pgmq.Effectful.Telemetry: messagingRoutingKey :: Text
- Pgmq.Effectful.Telemetry: messagingSystem :: Text
+ Pgmq.Effectful: PgmqAcquisitionTimeout :: PgmqRuntimeError
+ Pgmq.Effectful: PgmqConnectionError :: ConnectionError -> PgmqRuntimeError
+ Pgmq.Effectful: PgmqSessionError :: SessionError -> PgmqRuntimeError
+ Pgmq.Effectful: data PgmqRuntimeError
+ Pgmq.Effectful: fromUsageError :: UsageError -> PgmqRuntimeError
+ Pgmq.Effectful: isTransient :: PgmqRuntimeError -> Bool
+ Pgmq.Effectful.Interpreter: PgmqAcquisitionTimeout :: PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: PgmqConnectionError :: ConnectionError -> PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: PgmqSessionError :: SessionError -> PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: data PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: fromUsageError :: UsageError -> PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: instance GHC.Classes.Eq Pgmq.Effectful.Interpreter.PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: instance GHC.Internal.Exception.Type.Exception Pgmq.Effectful.Interpreter.PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: instance GHC.Internal.Generics.Generic Pgmq.Effectful.Interpreter.PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: instance GHC.Internal.Show.Show Pgmq.Effectful.Interpreter.PgmqRuntimeError
+ Pgmq.Effectful.Interpreter: isTransient :: PgmqRuntimeError -> Bool
+ Pgmq.Effectful.Telemetry: db_operation :: AttributeKey Text
+ Pgmq.Effectful.Telemetry: db_system :: AttributeKey Text
+ Pgmq.Effectful.Telemetry: jsonToTraceHeaders :: Value -> TraceHeaders
+ Pgmq.Effectful.Telemetry: messaging_batch_messageCount :: AttributeKey Int64
+ Pgmq.Effectful.Telemetry: messaging_destination_name :: AttributeKey Text
+ Pgmq.Effectful.Telemetry: messaging_message_id :: AttributeKey Text
+ Pgmq.Effectful.Telemetry: messaging_operation :: AttributeKey Text
+ Pgmq.Effectful.Telemetry: messaging_system :: AttributeKey Text
+ Pgmq.Effectful.Telemetry: traceHeadersToJson :: TraceHeaders -> Value
- Pgmq.Effectful: extractTraceContext :: TraceHeaders -> Maybe SpanContext
+ Pgmq.Effectful: extractTraceContext :: MonadIO m => TracerProvider -> TraceHeaders -> Context -> m Context
- Pgmq.Effectful: injectTraceContext :: Span -> IO TraceHeaders
+ Pgmq.Effectful: injectTraceContext :: MonadIO m => TracerProvider -> Context -> m TraceHeaders
- Pgmq.Effectful: readMessageWithContext :: forall (es :: [Effect]). Pgmq :> es => ReadMessage -> Eff es (Vector MessageWithContext)
+ Pgmq.Effectful: readMessageWithContext :: forall (es :: [Effect]). (Pgmq :> es, IOE :> es) => TracerProvider -> ReadMessage -> Eff es (Vector MessageWithContext)
- Pgmq.Effectful: runPgmq :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqError :> es) => Pool -> Eff (Pgmq ': es) a -> Eff es a
+ Pgmq.Effectful: runPgmq :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> Eff (Pgmq ': es) a -> Eff es a
- Pgmq.Effectful: runPgmqTraced :: forall (es :: [Effect]) a. IOE :> es => Pool -> Tracer -> Eff (Pgmq ': es) a -> Eff es a
+ Pgmq.Effectful: runPgmqTraced :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> Tracer -> Eff (Pgmq ': es) a -> Eff es a
- Pgmq.Effectful: runPgmqTracedWith :: forall (es :: [Effect]) a. IOE :> es => Pool -> TracingConfig -> Eff (Pgmq ': es) a -> Eff es a
+ Pgmq.Effectful: runPgmqTracedWith :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> TracingConfig -> Eff (Pgmq ': es) a -> Eff es a
- Pgmq.Effectful: sendMessageTraced :: forall (es :: [Effect]). (Pgmq :> es, IOE :> es) => Tracer -> QueueName -> MessageBody -> Maybe Value -> Eff es MessageId
+ Pgmq.Effectful: sendMessageTraced :: forall (es :: [Effect]). (Pgmq :> es, IOE :> es) => TracerProvider -> QueueName -> MessageBody -> Maybe Value -> Eff es MessageId
- Pgmq.Effectful: type MessageWithContext = (Message, Maybe SpanContext)
+ Pgmq.Effectful: type MessageWithContext = (Message, Context)
- Pgmq.Effectful: type TraceHeaders = [(ByteString, ByteString)]
+ Pgmq.Effectful: type TraceHeaders = RequestHeaders
- Pgmq.Effectful.Interpreter: runPgmq :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqError :> es) => Pool -> Eff (Pgmq ': es) a -> Eff es a
+ Pgmq.Effectful.Interpreter: runPgmq :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> Eff (Pgmq ': es) a -> Eff es a
- Pgmq.Effectful.Interpreter.Traced: runPgmqTraced :: forall (es :: [Effect]) a. IOE :> es => Pool -> Tracer -> Eff (Pgmq ': es) a -> Eff es a
+ Pgmq.Effectful.Interpreter.Traced: runPgmqTraced :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> Tracer -> Eff (Pgmq ': es) a -> Eff es a
- Pgmq.Effectful.Interpreter.Traced: runPgmqTracedWith :: forall (es :: [Effect]) a. IOE :> es => Pool -> TracingConfig -> Eff (Pgmq ': es) a -> Eff es a
+ Pgmq.Effectful.Interpreter.Traced: runPgmqTracedWith :: forall (es :: [Effect]) a. (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> TracingConfig -> Eff (Pgmq ': es) a -> Eff es a
- Pgmq.Effectful.Telemetry: extractTraceContext :: TraceHeaders -> Maybe SpanContext
+ Pgmq.Effectful.Telemetry: extractTraceContext :: MonadIO m => TracerProvider -> TraceHeaders -> Context -> m Context
- Pgmq.Effectful.Telemetry: injectTraceContext :: Span -> IO TraceHeaders
+ Pgmq.Effectful.Telemetry: injectTraceContext :: MonadIO m => TracerProvider -> Context -> m TraceHeaders
- Pgmq.Effectful.Telemetry: type TraceHeaders = [(ByteString, ByteString)]
+ Pgmq.Effectful.Telemetry: type TraceHeaders = RequestHeaders
- Pgmq.Effectful.Traced: readMessageWithContext :: forall (es :: [Effect]). Pgmq :> es => ReadMessage -> Eff es (Vector MessageWithContext)
+ Pgmq.Effectful.Traced: readMessageWithContext :: forall (es :: [Effect]). (Pgmq :> es, IOE :> es) => TracerProvider -> ReadMessage -> Eff es (Vector MessageWithContext)
- Pgmq.Effectful.Traced: sendMessageTraced :: forall (es :: [Effect]). (Pgmq :> es, IOE :> es) => Tracer -> QueueName -> MessageBody -> Maybe Value -> Eff es MessageId
+ Pgmq.Effectful.Traced: sendMessageTraced :: forall (es :: [Effect]). (Pgmq :> es, IOE :> es) => TracerProvider -> QueueName -> MessageBody -> Maybe Value -> Eff es MessageId
- Pgmq.Effectful.Traced: type MessageWithContext = (Message, Maybe SpanContext)
+ Pgmq.Effectful.Traced: type MessageWithContext = (Message, Context)
Files
- CHANGELOG.md +137/−0
- pgmq-effectful.cabal +60/−15
- src/Pgmq/Effectful.hs +15/−2
- src/Pgmq/Effectful/Interpreter.hs +67/−5
- src/Pgmq/Effectful/Interpreter/Traced.hs +264/−192
- src/Pgmq/Effectful/Telemetry.hs +124/−90
- src/Pgmq/Effectful/Traced.hs +55/−73
- test/ClassificationSpec.hs +66/−0
- test/EphemeralDb.hs +44/−0
- test/Main.hs +24/−0
- test/PlainInterpreterSpec.hs +68/−0
- test/TracedInterpreterSpec.hs +311/−0
CHANGELOG.md view
@@ -1,5 +1,142 @@ # Revision history for pgmq-effectful +## 0.2.0.0 -- 2026-04-23++### Breaking Changes++* **OpenTelemetry semantic conventions updated to spec v1.24.** The+ traced interpreter now emits attribute names compliant with+ OpenTelemetry Semantic Conventions+ [v1.24](https://github.com/open-telemetry/semantic-conventions/tree/v1.24.0),+ sourced as typed `AttributeKey` values from the+ [`hs-opentelemetry-semantic-conventions`](https://hackage.haskell.org/package/hs-opentelemetry-semantic-conventions)+ library. Breaking consequences:++ * `messaging.operation.type` → `messaging.operation`. Values change+ from the post-v1.24 vocabulary (`"send"`, `"receive"`) to the+ v1.24 vocabulary (`"publish"`, `"receive"`). Note the verb change:+ `send` → `publish`.+ * `db.operation.name` → `db.operation`. Values are the pgmq SQL+ function name (`"pgmq.send"`, `"pgmq.read"`, `"pgmq.archive"`, …),+ not the previous free-form label.+ * `messaging.destination.routing_key` (ad-hoc, not in v1.24) is+ gone. Topic-send operations now emit the routing key as+ `messaging.destination.name`, since for pgmq topics the routing+ key is the logical destination. `ValidateRoutingKey`,+ `ValidateTopicPattern`, and `TestRouting` no longer emit a+ routing-key attribute at all.+ * Span names changed from `"pgmq <op>"` to the v1.24+ `"<operation> <destination>"` form. Examples:+ `"pgmq send"` → `"publish my-queue"`,+ `"pgmq read"` → `"receive my-queue"`,+ `"pgmq archive"` → `"pgmq.archive my-queue"`,+ `"pgmq list_queues"` → `"pgmq.list_queues"`.+ * Queue management operations (`createQueue`, `dropQueue`,+ `createPartitionedQueue`, `createUnloggedQueue`) moved from+ `Producer` span kind to `Internal`. v1.24 reserves `Producer` for+ message publishes; queue administration is not a publish.+ * Span status on failure now carries a short non-PII label+ (`"pool.acquisition_timeout"`, `"pool.connection.networking"`,+ `"pool.session.statement"`, …) instead of the raw `show` of+ `UsageError` (which bakes in SQL text and parameter values).+ Full detail remains on the standard `exception` event via+ `OpenTelemetry.Trace.Core.recordException`.++ Dashboards, saved queries, and alerts keyed on the old attribute+ names or span-name format need to be updated.++* **Trace context propagation now uses the tracer provider's+ configured propagator** (W3C by default, but B3 / Datadog / … work+ out of the box when the provider is configured with them). The old+ implementation hard-wired `hs-opentelemetry-propagator-w3c`.++ * `Pgmq.Effectful.Telemetry.injectTraceContext` / `extractTraceContext`+ take a `TracerProvider` (previously an `OTel.Span` / raw carrier).+ * `Pgmq.Effectful.Telemetry.TraceHeaders` is now+ `Network.HTTP.Types.RequestHeaders` (case-insensitive header names),+ matching the carrier type every propagator uses. The previous+ `[(ByteString, ByteString)]` alias is gone.+ * `Pgmq.Effectful.Traced.sendMessageTraced` takes a `TracerProvider`.+ * `Pgmq.Effectful.Traced.readMessageWithContext` takes a+ `TracerProvider` and now returns+ `Vector (Message, OpenTelemetry.Context.Context)`.+ Callers that specifically need the raw `SpanContext` can recover+ it via `Context.lookupSpan >>= getSpanContext`.+ * Two new helpers, `traceHeadersToJson` and `jsonToTraceHeaders`,+ handle the pgmq-over-jsonb serialization boundary.++ The `hs-opentelemetry-propagator-w3c` dependency was dropped from+ the library; the SDK still installs the W3C propagator as the+ default.++* Renamed the interpreter error type from `PgmqError` to+ `PgmqRuntimeError` and replaced its opaque `PgmqPoolError UsageError`+ constructor with three structured constructors:++ data PgmqRuntimeError+ = PgmqAcquisitionTimeout+ | PgmqConnectionError Hasql.Errors.ConnectionError+ | PgmqSessionError Hasql.Errors.SessionError++ The old `PgmqError`/`PgmqPoolError` names are retained with a+ DEPRECATED pragma and will be removed in 0.3.0.0.++* `runPgmq`'s error constraint changed from `Error PgmqError :> es` to+ `Error PgmqRuntimeError :> es`. Update any+ `runError @PgmqError` annotation to `runError @PgmqRuntimeError`.++* `runPgmqTraced` and `runPgmqTracedWith` now require+ `Error PgmqRuntimeError :> es`. Previously they had *no* error+ constraint and threw a `fail`-derived `IOError` outside the Error+ effect channel, which meant any `runError` wrapper around a traced+ program was a no-op. Code that relied on that silent swallowing now+ receives typed errors; update call sites to wrap with+ `runError @PgmqRuntimeError`.++### New Features++* `fromUsageError :: Hasql.Pool.UsageError -> PgmqRuntimeError` —+ convert raw hasql-pool errors into the pgmq-effectful error type.+ Useful when layering `pgmq-effectful` over code that already calls+ `Pool.use` directly.++* `isTransient :: PgmqRuntimeError -> Bool` — classification helper for+ retry logic. Returns True for acquisition timeouts, networking+ connection errors, unrecognized libpq connection errors, and+ session-level connection drops; False for authentication,+ compatibility, missing-types, statement, script, and driver errors.++* New test suite `pgmq-effectful-test` asserts that both interpreters+ surface typed `PgmqRuntimeError` values through the Error channel.++### Migration Guide++Before:++ import Pgmq.Effectful (PgmqError (..), runPgmq)++ handler =+ runEff . runError @PgmqError . runPgmq pool $ action++After:++ import Pgmq.Effectful (PgmqRuntimeError (..), runPgmq)++ handler =+ runEff . runError @PgmqRuntimeError . runPgmq pool $ action++For retry logic:++ import Pgmq.Effectful (isTransient)++ retryIfTransient action = do+ result <- runError @PgmqRuntimeError action+ case result of+ Right a -> pure (Right a)+ Left (_, err)+ | isTransient err -> retryIfTransient action+ | otherwise -> pure (Left err)+ ## 0.1.3.0 -- 2026-03-12 ### Other Changes
pgmq-effectful.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: pgmq-effectful-version: 0.1.3.0+version: 0.2.0.0 synopsis: Effectful effects for PGMQ (PostgreSQL Message Queue) description: Effectful effects and interpreters for pgmq-hs, a Haskell client@@ -47,20 +47,65 @@ TypeOperators build-depends:- , aeson >=2.0 && <2.3- , base >=4.18 && <5- , bytestring >=0.11 && <0.13- , effectful-core ^>=2.5 || ^>=2.6- , hasql ^>=1.10- , hasql-pool ^>=1.4- , hs-opentelemetry-api >=0.2 && <0.4- , hs-opentelemetry-propagator-w3c >=0.0 && <0.2- , pgmq-core >=0.1.3 && <0.2- , pgmq-hasql >=0.1.3 && <0.2- , text >=2.0 && <2.2- , unliftio >=0.2 && <0.3- , unordered-containers >=0.2 && <0.3- , vector ^>=0.13+ , aeson >=2.0 && <2.3+ , base >=4.18 && <5+ , bytestring >=0.11 && <0.13+ , case-insensitive >=1.2 && <1.3+ , effectful-core ^>=2.5 || ^>=2.6+ , hasql ^>=1.10+ , hasql-pool ^>=1.4+ , hs-opentelemetry-api >=0.2 && <0.4+ , hs-opentelemetry-semantic-conventions >=0.1 && <0.2+ , http-types >=0.12 && <0.13+ , pgmq-core >=0.2 && <0.3+ , pgmq-hasql >=0.2 && <0.3+ , text >=2.0 && <2.2+ , unliftio >=0.2 && <0.3+ , unordered-containers >=0.2 && <0.3+ , vector ^>=0.13 hs-source-dirs: src default-language: GHC2024++test-suite pgmq-effectful-test+ import: warnings+ default-language: GHC2024+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ other-modules:+ ClassificationSpec+ EphemeralDb+ PlainInterpreterSpec+ TracedInterpreterSpec++ default-extensions:+ DataKinds+ ImportQualifiedPost+ LambdaCase+ OverloadedRecordDot+ OverloadedStrings+ TypeOperators++ build-depends:+ , aeson >=2.0 && <2.3+ , base >=4.18 && <5+ , effectful-core ^>=2.5 || ^>=2.6+ , ephemeral-pg >=0.2.1+ , hasql ^>=1.10+ , hasql-pool ^>=1.4+ , hs-opentelemetry-api >=0.2 && <0.4+ , hs-opentelemetry-exporter-in-memory >=0.0 && <0.1+ , hs-opentelemetry-propagator-w3c >=0.0 && <0.2+ , hs-opentelemetry-sdk >=0.0 && <0.2+ , hs-opentelemetry-semantic-conventions >=0.1 && <0.2+ , pgmq-core >=0.2 && <0.3+ , pgmq-effectful+ , pgmq-migration >=0.2 && <0.3+ , random ^>=1.2+ , tasty ^>=1.5+ , tasty-hunit ^>=0.10+ , text >=2.0 && <2.2+ , unordered-containers >=0.2 && <0.3+ , vector ^>=0.13
src/Pgmq/Effectful.hs view
@@ -4,7 +4,6 @@ -- * Interpreters runPgmq,- PgmqError (..), -- ** Traced Interpreters runPgmqTraced,@@ -12,6 +11,14 @@ TracingConfig (..), defaultTracingConfig, + -- * Errors+ PgmqRuntimeError (..),+ fromUsageError,+ isTransient,++ -- ** Deprecated Error Types+ PgmqError (..),+ -- ** Traced Operations sendMessageTraced, readMessageWithContext,@@ -185,7 +192,13 @@ validateRoutingKey, validateTopicPattern, )-import Pgmq.Effectful.Interpreter (PgmqError (..), runPgmq)+import Pgmq.Effectful.Interpreter+ ( PgmqError (..),+ PgmqRuntimeError (..),+ fromUsageError,+ isTransient,+ runPgmq,+ ) import Pgmq.Effectful.Interpreter.Traced ( TracingConfig (..), defaultTracingConfig,
src/Pgmq/Effectful/Interpreter.hs view
@@ -3,28 +3,90 @@ runPgmq, -- * Error Types+ PgmqRuntimeError (..),+ fromUsageError,+ isTransient,++ -- * Legacy Error Types (deprecated; will be removed in 0.3.0) PgmqError (..), ) where +import Control.Exception (Exception) import Effectful (Eff, IOE, (:>)) import Effectful qualified import Effectful.Dispatch.Dynamic (interpret) import Effectful.Error.Static (Error, throwError)+import GHC.Generics (Generic)+import Hasql.Errors qualified as HasqlErrors import Hasql.Pool (Pool, UsageError) import Hasql.Pool qualified as Pool import Hasql.Session qualified import Pgmq.Effectful.Effect (Pgmq (..)) import Pgmq.Hasql.Sessions qualified as Sessions --- | Error type for pgmq operations.+-- | Structured runtime error for pgmq-effectful operations.+--+-- Constructors mirror 'Hasql.Pool.UsageError' so the mapping is direct.+-- The inner 'HasqlErrors.ConnectionError' and 'HasqlErrors.SessionError'+-- types are sum types with detailed constructors; see the Hasql+-- documentation for their full shapes.+data PgmqRuntimeError+ = -- | Timed out waiting for a connection from the pool.+ PgmqAcquisitionTimeout+ | -- | Failed to establish a connection to PostgreSQL.+ PgmqConnectionError HasqlErrors.ConnectionError+ | -- | Error during session execution (SQL statement, decoding, etc.).+ PgmqSessionError HasqlErrors.SessionError+ deriving stock (Show, Eq, Generic)++instance Exception PgmqRuntimeError++-- | Convert hasql-pool's 'UsageError' into the structured+-- 'PgmqRuntimeError'.+fromUsageError :: UsageError -> PgmqRuntimeError+fromUsageError = \case+ Pool.AcquisitionTimeoutUsageError -> PgmqAcquisitionTimeout+ Pool.ConnectionUsageError e -> PgmqConnectionError e+ Pool.SessionUsageError e -> PgmqSessionError e++-- | Is this error plausibly transient — i.e., worth retrying?+--+-- Returns 'True' for acquisition timeouts, networking connection errors,+-- uncategorized libpq connection errors, and session-level connection+-- drops. All other errors (authentication failure, compatibility+-- mismatches, missing types, statement errors, driver bugs) are treated+-- as permanent.+--+-- Note: 'HasqlErrors.OtherConnectionError' is classed as transient here+-- despite hasql's documentation calling it \"not transient by default\",+-- because it is the catch-all for unrecognized libpq errors and classing+-- the unknown as transient errs toward letting retries happen.+isTransient :: PgmqRuntimeError -> Bool+isTransient = \case+ PgmqAcquisitionTimeout -> True+ PgmqConnectionError e -> case e of+ HasqlErrors.NetworkingConnectionError _ -> True+ HasqlErrors.AuthenticationConnectionError _ -> False+ HasqlErrors.CompatibilityConnectionError _ -> False+ HasqlErrors.OtherConnectionError _ -> True+ PgmqSessionError e -> case e of+ HasqlErrors.ConnectionSessionError _ -> True+ HasqlErrors.StatementSessionError {} -> False+ HasqlErrors.ScriptSessionError {} -> False+ HasqlErrors.MissingTypesSessionError _ -> False+ HasqlErrors.DriverSessionError _ -> False++-- | Legacy error type. Retained for one release cycle; migrate to+-- 'PgmqRuntimeError'. Will be removed in pgmq-effectful 0.3.0. newtype PgmqError = PgmqPoolError UsageError deriving stock (Show)+{-# DEPRECATED PgmqError, PgmqPoolError "Use PgmqRuntimeError instead (available from Pgmq.Effectful). The legacy types will be removed in pgmq-effectful 0.3.0." #-} -- | Run the Pgmq effect using a connection pool.--- Errors are thrown via the 'Error' effect.+-- Errors are thrown via the 'Error' effect as 'PgmqRuntimeError'. runPgmq ::- (IOE :> es, Error PgmqError :> es) =>+ (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> Eff (Pgmq : es) a -> Eff es a@@ -92,12 +154,12 @@ -- Internal helper runSession ::- (IOE :> es, Error PgmqError :> es) =>+ (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> Hasql.Session.Session a -> Eff es a runSession pool session = do result <- Effectful.liftIO $ Pool.use pool session case result of- Left err -> throwError $ PgmqPoolError err+ Left err -> throwError $ fromUsageError err Right a -> pure a
src/Pgmq/Effectful/Interpreter/Traced.hs view
@@ -3,11 +3,46 @@ -- 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 ----- * Producer spans: Queue creation, message send operations--- * Consumer spans: Message read operations--- * Internal spans: Message lifecycle (delete, archive, visibility timeout)+-- * '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 --@@ -22,7 +57,7 @@ -- pool <- ... -- -- runEff--- . runError @PgmqError+-- . runError @PgmqRuntimeError -- . runPgmqTraced pool tracer -- $ do -- createQueue "my-queue"@@ -40,18 +75,24 @@ 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 Hasql.Pool (Pool, UsageError)+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 (Attribute (..), PrimitiveAttribute (..))-import OpenTelemetry.Attributes.Map qualified as AttrMap+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@@ -76,9 +117,44 @@ 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) =>+ (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> OTel.Tracer -> Eff (Pgmq : es) a ->@@ -87,323 +163,319 @@ -- | Run the Pgmq effect with custom tracing configuration. runPgmqTracedWith ::- (IOE :> es) =>+ (IOE :> es, Error PgmqRuntimeError :> es) => Pool -> TracingConfig -> Eff (Pgmq : es) a -> Eff es a runPgmqTracedWith pool config = interpret $ \_ -> \case- -- Queue Management (Producer spans)+ -- Queue Management (Internal spans) CreateQueue q ->- withTracedSession config "pgmq create_queue" OTel.Producer q pool $+ withTracedOp config pool (queueOp "pgmq.create" OTel.Internal q) $ Sessions.createQueue q DropQueue q ->- withTracedSession config "pgmq drop_queue" OTel.Producer q pool $+ withTracedOp config pool (queueOp "pgmq.drop_queue" OTel.Internal q) $ Sessions.dropQueue q CreatePartitionedQueue pq@(Types.CreatePartitionedQueue qn _ _) ->- withTracedSession config "pgmq create_partitioned_queue" OTel.Producer qn pool $+ withTracedOp config pool (queueOp "pgmq.create_partitioned" OTel.Internal qn) $ Sessions.createPartitionedQueue pq CreateUnloggedQueue q ->- withTracedSession config "pgmq create_unlogged_queue" OTel.Producer q pool $+ withTracedOp config pool (queueOp "pgmq.create_unlogged" OTel.Internal q) $ Sessions.createUnloggedQueue q DetachArchive _q -> pure () EnableNotifyInsert cfg@(Types.EnableNotifyInsert qn _) ->- withTracedSession config "pgmq enable_notify_insert" OTel.Internal qn pool $+ withTracedOp config pool (queueOp "pgmq.enable_notify_insert" OTel.Internal qn) $ Sessions.enableNotifyInsert cfg DisableNotifyInsert q ->- withTracedSession config "pgmq disable_notify_insert" OTel.Internal q pool $+ withTracedOp config pool (queueOp "pgmq.disable_notify_insert" OTel.Internal q) $ Sessions.disableNotifyInsert q CreateFifoIndex q ->- withTracedSession config "pgmq create_fifo_index" OTel.Internal q pool $+ withTracedOp config pool (queueOp "pgmq.create_fifo_index" OTel.Internal q) $ Sessions.createFifoIndex q CreateFifoIndexesAll ->- withTracedSessionNoQueue config "pgmq create_fifo_indexes_all" OTel.Internal pool $+ withTracedOp config pool (defaultOpInfo "pgmq.create_fifo_indexes_all" OTel.Internal) $ Sessions.createFifoIndexesAll- -- Message Operations - Send (Producer spans)+ -- Message Operations - Send (Producer spans, messaging.operation=publish) SendMessage msg@(Types.SendMessage qn _ _) ->- withTracedSession config "pgmq send" OTel.Producer qn pool $+ withTracedOp config pool (publishOp "pgmq.send" qn) $ Sessions.sendMessage msg SendMessageForLater msg@(Types.SendMessageForLater qn _ _) ->- withTracedSession config "pgmq send" OTel.Producer qn pool $+ withTracedOp config pool (publishOp "pgmq.send" qn) $ Sessions.sendMessageForLater msg BatchSendMessage msg@(Types.BatchSendMessage qn bodies _) ->- withTracedSessionWithCount config "pgmq send_batch" OTel.Producer qn (length bodies) pool $+ withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $ Sessions.batchSendMessage msg BatchSendMessageForLater msg@(Types.BatchSendMessageForLater qn bodies _) ->- withTracedSessionWithCount config "pgmq send_batch" OTel.Producer qn (length bodies) pool $+ withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $ Sessions.batchSendMessageForLater msg SendMessageWithHeaders msg@(Types.SendMessageWithHeaders qn _ _ _) ->- withTracedSession config "pgmq send" OTel.Producer qn pool $+ withTracedOp config pool (publishOp "pgmq.send" qn) $ Sessions.sendMessageWithHeaders msg SendMessageWithHeadersForLater msg@(Types.SendMessageWithHeadersForLater qn _ _ _) ->- withTracedSession config "pgmq send" OTel.Producer qn pool $+ withTracedOp config pool (publishOp "pgmq.send" qn) $ Sessions.sendMessageWithHeadersForLater msg BatchSendMessageWithHeaders msg@(Types.BatchSendMessageWithHeaders qn bodies _ _) ->- withTracedSessionWithCount config "pgmq send_batch" OTel.Producer qn (length bodies) pool $+ withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $ Sessions.batchSendMessageWithHeaders msg BatchSendMessageWithHeadersForLater msg@(Types.BatchSendMessageWithHeadersForLater qn bodies _ _) ->- withTracedSessionWithCount config "pgmq send_batch" OTel.Producer qn (length bodies) pool $+ withTracedOp config pool (publishBatchOp "pgmq.send_batch" qn (length bodies)) $ Sessions.batchSendMessageWithHeadersForLater msg- -- Message Operations - Read (Consumer spans)+ -- Message Operations - Read (Consumer spans, messaging.operation=receive) ReadMessage query@(Types.ReadMessage qn _ _ _) ->- withTracedSession config "pgmq read" OTel.Consumer qn pool $+ withTracedOp config pool (receiveOp "pgmq.read" qn) $ Sessions.readMessage query ReadWithPoll query@(Types.ReadWithPollMessage qn _ _ _ _ _) ->- withTracedSession config "pgmq read_with_poll" OTel.Consumer qn pool $+ withTracedOp config pool (receiveOp "pgmq.read_with_poll" qn) $ Sessions.readWithPoll query Pop query@(Types.PopMessage qn _) ->- withTracedSession config "pgmq pop" OTel.Consumer qn pool $+ withTracedOp config pool (receiveOp "pgmq.pop" qn) $ Sessions.pop query -- FIFO Read (Consumer spans) ReadGrouped query@(Types.ReadGrouped qn _ _) ->- withTracedSession config "pgmq read_grouped" OTel.Consumer qn pool $+ withTracedOp config pool (receiveOp "pgmq.read_grouped" qn) $ Sessions.readGrouped query ReadGroupedWithPoll query@(Types.ReadGroupedWithPoll qn _ _ _ _) ->- withTracedSession config "pgmq read_grouped_with_poll" OTel.Consumer qn pool $+ withTracedOp config pool (receiveOp "pgmq.read_grouped_with_poll" qn) $ Sessions.readGroupedWithPoll query -- Round-robin FIFO Read (Consumer spans) ReadGroupedRoundRobin query@(Types.ReadGrouped qn _ _) ->- withTracedSession config "pgmq read_grouped_rr" OTel.Consumer qn pool $+ withTracedOp config pool (receiveOp "pgmq.read_grouped_rr" qn) $ Sessions.readGroupedRoundRobin query ReadGroupedRoundRobinWithPoll query@(Types.ReadGroupedWithPoll qn _ _ _ _) ->- withTracedSession config "pgmq read_grouped_rr_with_poll" OTel.Consumer qn pool $+ withTracedOp config pool (receiveOp "pgmq.read_grouped_rr_with_poll" qn) $ Sessions.readGroupedRoundRobinWithPoll query -- Message Lifecycle (Internal spans) DeleteMessage query@(Types.MessageQuery qn msgId) ->- withTracedSessionWithMsgId config "pgmq delete" OTel.Internal qn msgId pool $+ withTracedOp config pool ((queueOp "pgmq.delete" OTel.Internal qn) {opMessageId = Just msgId}) $ Sessions.deleteMessage query BatchDeleteMessages query@(Types.BatchMessageQuery qn msgIds) ->- withTracedSessionWithCount config "pgmq delete_batch" OTel.Internal qn (length msgIds) pool $+ withTracedOp config pool ((queueOp "pgmq.delete" OTel.Internal qn) {opBatchCount = Just (length msgIds)}) $ Sessions.batchDeleteMessages query ArchiveMessage query@(Types.MessageQuery qn msgId) ->- withTracedSessionWithMsgId config "pgmq archive" OTel.Internal qn msgId pool $+ withTracedOp config pool ((queueOp "pgmq.archive" OTel.Internal qn) {opMessageId = Just msgId}) $ Sessions.archiveMessage query BatchArchiveMessages query@(Types.BatchMessageQuery qn msgIds) ->- withTracedSessionWithCount config "pgmq archive_batch" OTel.Internal qn (length msgIds) pool $+ withTracedOp config pool ((queueOp "pgmq.archive" OTel.Internal qn) {opBatchCount = Just (length msgIds)}) $ Sessions.batchArchiveMessages query DeleteAllMessagesFromQueue q ->- withTracedSession config "pgmq purge_queue" OTel.Internal q pool $+ withTracedOp config pool (queueOp "pgmq.purge_queue" OTel.Internal q) $ Sessions.deleteAllMessagesFromQueue q ChangeVisibilityTimeout query@(Types.VisibilityTimeoutQuery qn msgId _) ->- withTracedSessionWithMsgId config "pgmq set_vt" OTel.Internal qn msgId pool $+ withTracedOp config pool ((queueOp "pgmq.set_vt" OTel.Internal qn) {opMessageId = Just msgId}) $ Sessions.changeVisibilityTimeout query BatchChangeVisibilityTimeout query@(Types.BatchVisibilityTimeoutQuery qn msgIds _) ->- withTracedSessionWithCount config "pgmq set_vt_batch" OTel.Internal qn (length msgIds) pool $+ 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 _) ->- withTracedSessionWithMsgId config "pgmq set_vt_at" OTel.Internal qn msgId pool $+ withTracedOp config pool ((queueOp "pgmq.set_vt" OTel.Internal qn) {opMessageId = Just msgId}) $ Sessions.setVisibilityTimeoutAt query BatchSetVisibilityTimeoutAt query@(Types.BatchVisibilityTimeoutAtQuery qn msgIds _) ->- withTracedSessionWithCount config "pgmq set_vt_at_batch" OTel.Internal qn (length msgIds) pool $+ 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) ->- withTracedSession config "pgmq bind_topic" OTel.Internal qn pool $+ withTracedOp config pool (queueOp "pgmq.bind_topic" OTel.Internal qn) $ Sessions.bindTopic params UnbindTopic params@(Types.UnbindTopic _ qn) ->- withTracedSession config "pgmq unbind_topic" OTel.Internal qn pool $+ withTracedOp config pool (queueOp "pgmq.unbind_topic" OTel.Internal qn) $ Sessions.unbindTopic params ValidateRoutingKey key ->- withTracedSessionWithRoutingKey config "pgmq validate_routing_key" OTel.Internal key pool $+ withTracedOp config pool (defaultOpInfo "pgmq.validate_routing_key" OTel.Internal) $ Sessions.validateRoutingKey key- ValidateTopicPattern _pat ->- withTracedSessionNoQueue config "pgmq validate_topic_pattern" OTel.Internal pool $- Sessions.validateTopicPattern _pat+ ValidateTopicPattern pat ->+ withTracedOp config pool (defaultOpInfo "pgmq.validate_topic_pattern" OTel.Internal) $+ Sessions.validateTopicPattern pat TestRouting key ->- withTracedSessionWithRoutingKey config "pgmq test_routing" OTel.Internal key pool $+ withTracedOp config pool (defaultOpInfo "pgmq.test_routing" OTel.Internal) $ Sessions.testRouting key ListTopicBindings ->- withTracedSessionNoQueue config "pgmq list_topic_bindings" OTel.Internal pool $+ withTracedOp config pool (defaultOpInfo "pgmq.list_topic_bindings" OTel.Internal) $ Sessions.listTopicBindings ListTopicBindingsForQueue q ->- withTracedSession config "pgmq list_topic_bindings" OTel.Internal q pool $+ 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 _ _) ->- withTracedSessionWithRoutingKey config "pgmq send_topic" OTel.Producer rk pool $+ withTracedOp config pool (publishTopicOp "pgmq.send_topic" rk) $ Sessions.sendTopic msg SendTopicWithHeaders msg@(Types.SendTopicWithHeaders rk _ _ _) ->- withTracedSessionWithRoutingKey config "pgmq send_topic" OTel.Producer rk pool $+ withTracedOp config pool (publishTopicOp "pgmq.send_topic" rk) $ Sessions.sendTopicWithHeaders msg BatchSendTopic msg@(Types.BatchSendTopic rk bodies _) ->- withTracedSessionWithRoutingKeyAndCount config "pgmq send_batch_topic" OTel.Producer rk (length bodies) pool $+ withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $ Sessions.batchSendTopic msg BatchSendTopicForLater msg@(Types.BatchSendTopicForLater rk bodies _) ->- withTracedSessionWithRoutingKeyAndCount config "pgmq send_batch_topic" OTel.Producer rk (length bodies) pool $+ withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $ Sessions.batchSendTopicForLater msg BatchSendTopicWithHeaders msg@(Types.BatchSendTopicWithHeaders rk bodies _ _) ->- withTracedSessionWithRoutingKeyAndCount config "pgmq send_batch_topic" OTel.Producer rk (length bodies) pool $+ withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $ Sessions.batchSendTopicWithHeaders msg BatchSendTopicWithHeadersForLater msg@(Types.BatchSendTopicWithHeadersForLater rk bodies _ _) ->- withTracedSessionWithRoutingKeyAndCount config "pgmq send_batch_topic" OTel.Producer rk (length bodies) pool $+ withTracedOp config pool (publishTopicBatchOp "pgmq.send_batch_topic" rk (length bodies)) $ Sessions.batchSendTopicWithHeadersForLater msg -- Notification Management (Internal spans, pgmq 1.11.0+) ListNotifyInsertThrottles ->- withTracedSessionNoQueue config "pgmq list_notify_insert_throttles" OTel.Internal pool $+ withTracedOp config pool (defaultOpInfo "pgmq.list_notify_insert_throttles" OTel.Internal) $ Sessions.listNotifyInsertThrottles UpdateNotifyInsert params@(Types.UpdateNotifyInsert qn _) ->- withTracedSession config "pgmq update_notify_insert" OTel.Internal qn pool $+ withTracedOp config pool (queueOp "pgmq.update_notify_insert" OTel.Internal qn) $ Sessions.updateNotifyInsert params -- Queue Observability (Internal spans) ListQueues ->- withTracedSessionNoQueue config "pgmq list_queues" OTel.Internal pool $+ withTracedOp config pool (defaultOpInfo "pgmq.list_queues" OTel.Internal) $ Sessions.listQueues QueueMetrics q ->- withTracedSession config "pgmq metrics" OTel.Internal q pool $+ withTracedOp config pool (queueOp "pgmq.metrics" OTel.Internal q) $ Sessions.queueMetrics q AllQueueMetrics ->- withTracedSessionNoQueue config "pgmq metrics_all" OTel.Internal pool $+ withTracedOp config pool (defaultOpInfo "pgmq.metrics_all" OTel.Internal) $ Sessions.allQueueMetrics --- Internal helpers+-- ---------------------------------------------------------------------+-- OpInfo constructors+-- --------------------------------------------------------------------- -withTracedSession ::- (IOE :> es) =>- TracingConfig ->- Text ->- OTel.SpanKind ->- QueueName ->- Pool ->- Hasql.Session.Session a ->- Eff es a-withTracedSession config spanName kind queueName pool session = do- let args = OTel.defaultSpanArguments {OTel.kind = kind}- Effectful.liftIO $ OTel.inSpan' config.tracer spanName args $ \s -> do- addQueueAttributes s queueName- runSessionIO config s pool session+-- | '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)} -withTracedSessionNoQueue ::- (IOE :> es) =>- TracingConfig ->- Text ->- OTel.SpanKind ->- Pool ->- Hasql.Session.Session a ->- Eff es a-withTracedSessionNoQueue config spanName kind pool session = do- let args = OTel.defaultSpanArguments {OTel.kind = kind}- Effectful.liftIO $ OTel.inSpan' config.tracer spanName args $ \s -> do- addBaseAttributes s- runSessionIO config s pool session+-- | 'OpInfo' for a @publish@ (send) on a queue.+publishOp :: Text -> QueueName -> OpInfo+publishOp fn qn =+ (queueOp fn OTel.Producer qn) {opMessagingKind = Just "publish"} -withTracedSessionWithMsgId ::- (IOE :> es) =>+-- | '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 ->- Text ->- OTel.SpanKind ->- QueueName ->- MessageId -> Pool ->+ OpInfo -> Hasql.Session.Session a -> Eff es a-withTracedSessionWithMsgId config spanName kind queueName msgId pool session = do- let args = OTel.defaultSpanArguments {OTel.kind = kind}- Effectful.liftIO $ OTel.inSpan' config.tracer spanName args $ \s -> do- addQueueAttributes s queueName- addMessageIdAttribute s msgId- runSessionIO config s pool session+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 -withTracedSessionWithCount ::- (IOE :> es) =>- TracingConfig ->- Text ->- OTel.SpanKind ->- QueueName ->- Int ->- Pool ->- Hasql.Session.Session a ->+-- ---------------------------------------------------------------------+-- 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-withTracedSessionWithCount config spanName kind queueName count pool session = do- let args = OTel.defaultSpanArguments {OTel.kind = kind}- Effectful.liftIO $ OTel.inSpan' config.tracer spanName args $ \s -> do- addQueueAttributes s queueName- addBatchCountAttribute s count- runSessionIO config s pool session+throwOnLeft = \case+ Left err -> throwError err+ Right a -> pure a runSessionIO :: TracingConfig -> OTel.Span -> Pool -> Hasql.Session.Session a ->- IO a+ IO (Either PgmqRuntimeError a) runSessionIO config s pool session = do result <- Pool.use pool session case result of Left err -> do- when config.recordExceptions $ recordUsageError s err- OTel.setStatus s (OTel.Error $ T.pack $ show err)- fail $ "PgmqPoolError: " <> show err+ 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 a--recordUsageError :: OTel.Span -> UsageError -> IO ()-recordUsageError s err = do- OTel.addEvent s $- OTel.NewEvent- { OTel.newEventName = "exception",- OTel.newEventTimestamp = Nothing,- OTel.newEventAttributes =- AttrMap.fromList- [ ("exception.type", AttributeValue $ TextAttribute "UsageError"),- ("exception.message", AttributeValue $ TextAttribute $ T.pack $ show err)- ]- }--addQueueAttributes :: OTel.Span -> QueueName -> IO ()-addQueueAttributes s queueName = do- addBaseAttributes s- OTel.addAttribute s messagingDestinationName (queueNameToText queueName)--addBaseAttributes :: OTel.Span -> IO ()-addBaseAttributes s = do- OTel.addAttribute s messagingSystem ("pgmq" :: Text)- OTel.addAttribute s dbSystem ("postgresql" :: Text)--addMessageIdAttribute :: OTel.Span -> MessageId -> IO ()-addMessageIdAttribute s (MessageId msgId) =- OTel.addAttribute s messagingMessageId (T.pack $ show msgId)--addBatchCountAttribute :: OTel.Span -> Int -> IO ()-addBatchCountAttribute s count =- OTel.addAttribute s messagingBatchMessageCount count--addRoutingKeyAttribute :: OTel.Span -> RoutingKey -> IO ()-addRoutingKeyAttribute s rk =- OTel.addAttribute s messagingRoutingKey (routingKeyToText rk)--withTracedSessionWithRoutingKey ::- (IOE :> es) =>- TracingConfig ->- Text ->- OTel.SpanKind ->- RoutingKey ->- Pool ->- Hasql.Session.Session a ->- Eff es a-withTracedSessionWithRoutingKey config spanName kind routingKey pool session = do- let args = OTel.defaultSpanArguments {OTel.kind = kind}- Effectful.liftIO $ OTel.inSpan' config.tracer spanName args $ \s -> do- addBaseAttributes s- addRoutingKeyAttribute s routingKey- runSessionIO config s pool session+ pure $ Right a -withTracedSessionWithRoutingKeyAndCount ::- (IOE :> es) =>- TracingConfig ->- Text ->- OTel.SpanKind ->- RoutingKey ->- Int ->- Pool ->- Hasql.Session.Session a ->- Eff es a-withTracedSessionWithRoutingKeyAndCount config spanName kind routingKey count pool session = do- let args = OTel.defaultSpanArguments {OTel.kind = kind}- Effectful.liftIO $ OTel.inSpan' config.tracer spanName args $ \s -> do- addBaseAttributes s- addRoutingKeyAttribute s routingKey- addBatchCountAttribute s count- runSessionIO config s pool session+-- | 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"
src/Pgmq/Effectful/Telemetry.hs view
@@ -2,114 +2,148 @@ -- -- This module provides: ----- * W3C Trace Context propagation via message headers--- * OpenTelemetry semantic conventions for messaging systems+-- * Trace-context propagation through pgmq message headers, using the+-- tracer provider's /configured/ propagator (W3C Trace Context by+-- default, but any propagator installed on the provider will work —+-- B3, Datadog, …). The pgmq header payload is a JSON object whose+-- keys are header names (lower-cased) and whose values are strings.+-- * Re-exports of typed 'AttributeKey' values from+-- "OpenTelemetry.SemanticConventions" for the v1.24 attributes used+-- by the pgmq instrumentation.+--+-- The semantic-conventions names mirror OpenTelemetry specification+-- v1.24 as generated into @hs-opentelemetry-semantic-conventions@+-- 0.1.0.0. module Pgmq.Effectful.Telemetry- ( -- * Trace Context Operations+ ( -- * Trace Context Propagation injectTraceContext, extractTraceContext,+ traceHeadersToJson,+ jsonToTraceHeaders, mergeTraceHeaders, TraceHeaders, - -- * Semantic Conventions- messagingSystem,- messagingOperationType,- messagingDestinationName,- messagingMessageId,- messagingBatchMessageCount,- messagingRoutingKey,- dbSystem,- dbOperationName,+ -- * Semantic Convention Keys (re-exported from++ -- "OpenTelemetry.SemanticConventions")+ messaging_system,+ messaging_operation,+ messaging_destination_name,+ messaging_message_id,+ messaging_batch_messageCount,+ db_system,+ db_operation, ) where +import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Aeson (Value (..)) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KM-import Data.ByteString (ByteString)-import Data.Text (Text)+import Data.CaseInsensitive qualified as CI+import Data.Text qualified as T import Data.Text.Encoding qualified as TE-import OpenTelemetry.Propagator.W3CTraceContext qualified as W3C-import OpenTelemetry.Trace.Core qualified as OTel---- | Headers for W3C Trace Context propagation.--- Contains traceparent and optionally tracestate.-type TraceHeaders = [(ByteString, ByteString)]---- | Inject trace context into message headers (for producers).--- Creates traceparent and tracestate headers from current span context.-injectTraceContext :: OTel.Span -> IO TraceHeaders-injectTraceContext otelSpan = do- (traceparent, tracestate) <- W3C.encodeSpanContext otelSpan- pure- [ ("traceparent", traceparent),- ("tracestate", tracestate)- ]---- | Extract trace context from message headers (for consumers).--- Returns SpanContext that can be used as parent link.-extractTraceContext :: TraceHeaders -> Maybe OTel.SpanContext-extractTraceContext headers = do- let traceparent = lookup "traceparent" headers- tracestate = lookup "tracestate" headers- W3C.decodeSpanContext traceparent tracestate---- | Merge trace headers into existing PGMQ message headers.--- Preserves existing headers while adding trace context.-mergeTraceHeaders :: TraceHeaders -> Maybe Value -> Value-mergeTraceHeaders traceHeaders existingHeaders =- let traceObj =- Object $- KM.fromList- [ (Key.fromText "traceparent", toJsonValue $ lookup "traceparent" traceHeaders),- (Key.fromText "tracestate", toJsonValue $ lookup "tracestate" traceHeaders)- ]- in case existingHeaders of- Just (Object obj) -> Object $ KM.union (toKeyMap traceObj) obj- Just v -> Object $ KM.fromList [(Key.fromText "_original", v), (Key.fromText "_trace", traceObj)]- Nothing -> traceObj- where- toJsonValue :: Maybe ByteString -> Value- toJsonValue = maybe Null (String . TE.decodeUtf8)-- toKeyMap :: Value -> KM.KeyMap Value- toKeyMap (Object o) = o- toKeyMap _ = KM.empty---- OpenTelemetry Semantic Conventions for Messaging--- See: https://opentelemetry.io/docs/specs/semconv/messaging/+import Network.HTTP.Types (RequestHeaders)+import OpenTelemetry.Context qualified as Ctxt+import OpenTelemetry.Propagator (Propagator, extract, inject)+import OpenTelemetry.SemanticConventions+ ( db_operation,+ db_system,+ messaging_batch_messageCount,+ messaging_destination_name,+ messaging_message_id,+ messaging_operation,+ messaging_system,+ )+import OpenTelemetry.Trace.Core+ ( TracerProvider,+ getTracerProviderPropagators,+ ) --- | The messaging system identifier.--- Value: "pgmq"-messagingSystem :: Text-messagingSystem = "messaging.system"+-- | Carrier for propagated trace context. Same shape as+-- @http-types@' @RequestHeaders@ — @[(HeaderName, ByteString)]@, where+-- @HeaderName@ is case-insensitive — because that is the carrier type+-- used by every propagator in the @hs-opentelemetry@ ecosystem.+type TraceHeaders = RequestHeaders --- | The type of messaging operation.--- Values: "send", "receive", "process"-messagingOperationType :: Text-messagingOperationType = "messaging.operation.type"+-- | Inject trace context into carrier headers, using the tracer+-- provider's configured propagator.+--+-- Callers typically pass a 'Ctxt.Context' obtained from+-- 'OpenTelemetry.Context.ThreadLocal.getContext' (with any current+-- span inserted) so the propagator can write the current span's+-- context onto the carrier.+injectTraceContext ::+ (MonadIO m) =>+ TracerProvider ->+ Ctxt.Context ->+ m TraceHeaders+injectTraceContext provider ctxt =+ let propagator = getTracerProviderPropagators provider+ in injectWith propagator ctxt --- | The destination queue name.-messagingDestinationName :: Text-messagingDestinationName = "messaging.destination.name"+-- | Extract trace context from carrier headers, using the tracer+-- provider's configured propagator. Returns an updated 'Ctxt.Context'+-- carrying the extracted span context (if any) — suitable for starting+-- a child \"process\" span linked to the producer's trace.+extractTraceContext ::+ (MonadIO m) =>+ TracerProvider ->+ TraceHeaders ->+ Ctxt.Context ->+ m Ctxt.Context+extractTraceContext provider headers ctxt =+ let propagator = getTracerProviderPropagators provider+ in extract propagator headers ctxt --- | The unique message identifier.-messagingMessageId :: Text-messagingMessageId = "messaging.message.id"+-- | Propagator-generic inject wrapper (mainly useful for tests that+-- want to pin the propagator explicitly).+injectWith ::+ (MonadIO m) =>+ Propagator Ctxt.Context RequestHeaders RequestHeaders ->+ Ctxt.Context ->+ m TraceHeaders+injectWith propagator ctxt = liftIO $ inject propagator ctxt [] --- | Number of messages in a batch operation.-messagingBatchMessageCount :: Text-messagingBatchMessageCount = "messaging.batch.message_count"+-- | Encode carrier headers as a JSON object for storage in pgmq+-- message headers (jsonb). Header names are lower-cased (via+-- case-insensitive folding) so the on-wire representation is stable+-- regardless of the original case the propagator emitted.+traceHeadersToJson :: TraceHeaders -> Value+traceHeadersToJson = Object . traceHeadersKeyMap --- | The routing key for topic-based message routing.-messagingRoutingKey :: Text-messagingRoutingKey = "messaging.destination.routing_key"+-- | Decode a JSON object of trace headers back into the carrier+-- shape. Non-object inputs and non-string values are ignored.+jsonToTraceHeaders :: Value -> TraceHeaders+jsonToTraceHeaders = \case+ Object obj ->+ [ (CI.mk (TE.encodeUtf8 (Key.toText k)), TE.encodeUtf8 v)+ | (k, String v) <- KM.toList obj+ ]+ _ -> [] --- | The database system.--- Value: "postgresql"-dbSystem :: Text-dbSystem = "db.system"+-- | Merge propagated trace headers into an existing pgmq headers+-- value. Existing keys win — we never overwrite user-supplied headers.+mergeTraceHeaders :: TraceHeaders -> Maybe Value -> Value+mergeTraceHeaders traceHeaders existingHeaders =+ let traceObj = traceHeadersKeyMap traceHeaders+ in case existingHeaders of+ Just (Object obj) -> Object (KM.union obj traceObj)+ Just v ->+ Object+ ( KM.fromList+ [ (Key.fromText (T.pack "_original"), v),+ (Key.fromText (T.pack "_trace"), Object traceObj)+ ]+ )+ Nothing -> Object traceObj --- | The database operation name.-dbOperationName :: Text-dbOperationName = "db.operation.name"+traceHeadersKeyMap :: TraceHeaders -> KM.KeyMap Value+traceHeadersKeyMap =+ KM.fromList+ . fmap+ ( \(name, value) ->+ ( Key.fromText (TE.decodeUtf8 (CI.foldedCase name)),+ String (TE.decodeUtf8 value)+ )+ )
src/Pgmq/Effectful/Traced.hs view
@@ -1,20 +1,29 @@--- | Traced operations for PGMQ with automatic trace context propagation.+-- | Traced operations for PGMQ with automatic trace-context propagation. ----- This module provides higher-level operations that automatically inject--- W3C Trace Context into message headers (for producers) and extract--- trace context from received messages (for consumers).+-- Higher-level helpers layered on top of the Pgmq effect: --+-- * 'sendMessageTraced' writes the current trace context onto the+-- outgoing message headers using the tracer provider's /configured/+-- propagator (W3C Trace Context by default; swap in B3, Datadog, or+-- any other propagator by configuring the provider).+-- * 'readMessageWithContext' extracts the trace context from each+-- received message and returns an 'OTel.Context' suitable for+-- starting a child @process@ span that links back to the producer's+-- trace.+-- -- == Usage -- -- @--- -- Producer: Send with trace context--- sendMessageTraced tracer queueName body Nothing+-- -- Producer+-- provider <- OTel.getGlobalTracerProvider+-- sendMessageTraced provider queueName body Nothing ----- -- Consumer: Read with trace context extraction--- messagesWithCtx <- readMessageWithContext readQuery--- forM_ messagesWithCtx $ \(msg, maybeParentCtx) ->--- -- maybeParentCtx contains the SpanContext from the producer--- processMessage msg maybeParentCtx+-- -- Consumer+-- messagesWithCtx <- readMessageWithContext provider readQuery+-- forM_ messagesWithCtx $ \\(msg, parentCtx) ->+-- -- parentCtx carries the propagated parent context+-- OTel.inSpan tracer \"process\" OTel.defaultSpanArguments+-- (processMessage msg) -- @ module Pgmq.Effectful.Traced ( -- * Traced Send Operations@@ -27,10 +36,6 @@ where import Data.Aeson (Value (..))-import Data.Aeson.Key qualified as Key-import Data.Aeson.KeyMap qualified as KM-import Data.Text (Text)-import Data.Text.Encoding qualified as TE import Data.Vector (Vector) import Data.Vector qualified as V import Effectful (Eff, IOE, (:>))@@ -40,34 +45,39 @@ import OpenTelemetry.Trace.Core qualified as OTel import Pgmq.Effectful.Effect (Pgmq, readMessage, sendMessageWithHeaders) import Pgmq.Effectful.Telemetry+ ( extractTraceContext,+ injectTraceContext,+ jsonToTraceHeaders,+ mergeTraceHeaders,+ ) import Pgmq.Hasql.Statements.Types qualified as Types import Pgmq.Types --- | A message paired with its extracted trace context (if present).-type MessageWithContext = (Message, Maybe OTel.SpanContext)+-- | A message paired with the trace context extracted from its+-- headers. Use 'OpenTelemetry.Context.ThreadLocal.attachContext' (or+-- pass it to an @inSpan''@-shaped primitive that lets you set the+-- parent) to start a @process@ span linked to the producer's trace.+type MessageWithContext = (Message, Ctxt.Context) --- | Send a message with trace context automatically injected into headers.------ This creates traceparent and tracestate headers from the current span--- context, which can be extracted by consumers to link traces.+-- | Send a message with trace context injected into its headers. ----- If existing headers are provided, the trace headers are merged in.+-- The current 'Ctxt.Context' is fetched via+-- 'OpenTelemetry.Context.ThreadLocal.getContext' and handed to the+-- tracer provider's configured propagator, which writes the propagated+-- fields (traceparent/tracestate for W3C, x-b3-* for B3, etc.) onto+-- the message headers. Any user-supplied headers win against+-- propagator-written ones (the merge is additive). sendMessageTraced :: (Pgmq :> es, IOE :> es) =>- OTel.Tracer ->+ OTel.TracerProvider -> QueueName -> MessageBody -> Maybe Value -> Eff es MessageId-sendMessageTraced _tracer queueName body existingHeaders = do- -- Get current span context and inject into headers+sendMessageTraced provider queueName body existingHeaders = do ctx <- Effectful.liftIO CtxtLocal.getContext- traceHeaders <- case Ctxt.lookupSpan ctx of- Just s -> Effectful.liftIO $ injectTraceContext s- Nothing -> pure []-+ traceHeaders <- injectTraceContext provider ctx let mergedHeaders = MessageHeaders $ mergeTraceHeaders traceHeaders existingHeaders- sendMessageWithHeaders $ Types.SendMessageWithHeaders { queueName = queueName,@@ -76,51 +86,23 @@ delay = Nothing } --- | Read messages and extract trace context from headers.------ Returns messages paired with their extracted SpanContext (if present).--- The SpanContext can be used to create a child span that links to the--- producer's trace.+-- | Read messages and extract trace context from their headers. ----- @--- messagesWithCtx <- readMessageWithContext readQuery--- forM_ messagesWithCtx $ \(msg, maybeParentCtx) ->--- inSpan' tracer "process" (linkToParent maybeParentCtx) $ do--- processMessage msg--- @+-- The returned 'Ctxt.Context' for each message is the+-- 'Ctxt.empty' context updated with whatever fields the tracer+-- provider's propagator was able to parse from the headers — so a+-- caller can open a child @process@ span linked to the producer's+-- trace without knowing which propagator is in use. readMessageWithContext ::- (Pgmq :> es) =>+ (Pgmq :> es, IOE :> es) =>+ OTel.TracerProvider -> Types.ReadMessage -> Eff es (Vector MessageWithContext)-readMessageWithContext readQuery = do+readMessageWithContext provider readQuery = do messages <- readMessage readQuery- pure $ V.map extractContext messages- where- extractContext :: Message -> MessageWithContext- extractContext msg =- let ctx = extractTraceContextFromMessage msg- in (msg, ctx)-- extractTraceContextFromMessage :: Message -> Maybe OTel.SpanContext- extractTraceContextFromMessage msg = do- hdrs <- msg.headers- traceHeaders <- parseTraceHeaders hdrs- extractTraceContext traceHeaders-- parseTraceHeaders :: Value -> Maybe TraceHeaders- parseTraceHeaders (Object obj) = do- traceparent <- KM.lookup (Key.fromText "traceparent") obj >>= asText- let tracestate = KM.lookup (Key.fromText "tracestate") obj >>= asText- pure $- catMaybes- [ Just ("traceparent", TE.encodeUtf8 traceparent),- ("tracestate",) . TE.encodeUtf8 <$> tracestate- ]- parseTraceHeaders _ = Nothing-- asText :: Value -> Maybe Text- asText (String t) = Just t- asText _ = Nothing-- catMaybes :: [Maybe a] -> [a]- catMaybes = foldr (\x acc -> maybe acc (: acc) x) []+ Effectful.liftIO $+ V.forM messages $ \msg -> do+ parentCtx <- case msg.headers of+ Just hdrs -> extractTraceContext provider (jsonToTraceHeaders hdrs) Ctxt.empty+ Nothing -> pure Ctxt.empty+ pure (msg, parentCtx)
+ test/ClassificationSpec.hs view
@@ -0,0 +1,66 @@+{-# LANGUAGE OverloadedStrings #-}++module ClassificationSpec (tests) where++import Data.HashSet qualified as HashSet+import Hasql.Errors qualified as HasqlErrors+import Pgmq.Effectful+ ( PgmqRuntimeError (..),+ isTransient,+ )+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase)++tests :: TestTree+tests =+ testGroup+ "isTransient classification"+ [ testCase "acquisition timeout is transient" $+ assertBool "expected transient" (isTransient PgmqAcquisitionTimeout),+ testCase "networking connection error is transient" $+ assertBool+ "expected transient"+ ( isTransient+ (PgmqConnectionError (HasqlErrors.NetworkingConnectionError "refused"))+ ),+ testCase "authentication error is not transient" $+ assertBool+ "expected permanent"+ ( not $+ isTransient+ (PgmqConnectionError (HasqlErrors.AuthenticationConnectionError "bad password"))+ ),+ testCase "compatibility error is not transient" $+ assertBool+ "expected permanent"+ ( not $+ isTransient+ (PgmqConnectionError (HasqlErrors.CompatibilityConnectionError "version mismatch"))+ ),+ testCase "other connection error is treated as transient" $+ assertBool+ "expected transient"+ ( isTransient+ (PgmqConnectionError (HasqlErrors.OtherConnectionError "libpq says no"))+ ),+ testCase "connection-drop session error is transient" $+ assertBool+ "expected transient"+ ( isTransient+ (PgmqSessionError (HasqlErrors.ConnectionSessionError "dropped"))+ ),+ testCase "driver session error is not transient" $+ assertBool+ "expected permanent"+ ( not $+ isTransient+ (PgmqSessionError (HasqlErrors.DriverSessionError "bug"))+ ),+ testCase "missing-types session error is not transient" $+ assertBool+ "expected permanent"+ ( not $+ isTransient+ (PgmqSessionError (HasqlErrors.MissingTypesSessionError HashSet.empty))+ )+ ]
+ test/EphemeralDb.hs view
@@ -0,0 +1,44 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Test database infrastructure using ephemeral-pg.+--+-- This is a copy of pgmq-hasql\'s EphemeralDb helper, adjusted for+-- pgmq-effectful\'s test suite. Keeping a local copy avoids cross-package+-- test-helper sharing (which would require a shared test-helper library+-- or fragile cross-package hs-source-dirs).+module EphemeralDb+ ( withPgmqDb,+ withPgmqPool,+ StartError,+ )+where++import EphemeralPg+ ( StartError,+ connectionSettings,+ withCached,+ )+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Pgmq.Migration qualified as Migration++-- | Run an action with a temporary PostgreSQL database that has the+-- pgmq schema installed.+withPgmqDb :: (Pool.Pool -> IO a) -> IO (Either StartError a)+withPgmqDb action = withCached $ \db -> do+ let connSettings = connectionSettings db+ poolConfig =+ PoolConfig.settings+ [ PoolConfig.size 3,+ PoolConfig.staticConnectionSettings connSettings+ ]+ pool <- Pool.acquire poolConfig+ installResult <- Pool.use pool Migration.migrate+ case installResult of+ Left poolErr -> error $ "Failed to install pgmq schema: " <> show poolErr+ Right (Left migrationErr) -> error $ "Migration failed: " <> show migrationErr+ Right (Right ()) -> action pool++-- | Alias for 'withPgmqDb' kept for parallelism with pgmq-hasql.+withPgmqPool :: (Pool.Pool -> IO a) -> IO (Either StartError a)+withPgmqPool = withPgmqDb
+ test/Main.hs view
@@ -0,0 +1,24 @@+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++import ClassificationSpec qualified+import EphemeralDb (withPgmqPool)+import PlainInterpreterSpec qualified+import Test.Tasty (defaultMain, testGroup)+import TracedInterpreterSpec qualified++main :: IO ()+main = do+ result <- withPgmqPool $ \pool -> do+ let tree =+ testGroup+ "pgmq-effectful"+ [ ClassificationSpec.tests,+ PlainInterpreterSpec.tests pool,+ TracedInterpreterSpec.tests pool+ ]+ defaultMain tree+ case result of+ Left err -> error $ "Failed to start temp database: " <> show err+ Right () -> pure ()
+ test/PlainInterpreterSpec.hs view
@@ -0,0 +1,68 @@+{-# LANGUAGE OverloadedStrings #-}++module PlainInterpreterSpec (tests) where++import Effectful (runEff)+import Effectful.Error.Static (runError)+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Pgmq.Effectful+ ( MessageId (..),+ MessageQuery (..),+ PgmqRuntimeError (..),+ deleteMessage,+ listQueues,+ parseQueueName,+ runPgmq,+ )+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase)++tests :: Pool.Pool -> TestTree+tests pool =+ testGroup+ "Plain interpreter error propagation"+ [ testCase "statement error surfaces PgmqSessionError" $ do+ bogus <- case parseQueueName "queue_that_does_not_exist_xyz" of+ Right q -> pure q+ Left err -> assertFailure ("could not build test queue name: " <> show err) >> fail ""+ result <-+ runEff+ . runError @PgmqRuntimeError+ . runPgmq pool+ $ deleteMessage+ MessageQuery+ { queueName = bogus,+ messageId = MessageId 1+ }+ case result of+ Left (_cs, PgmqSessionError _) -> pure ()+ Left (_cs, other) ->+ assertFailure $+ "Expected PgmqSessionError, got " <> show other+ Right _ ->+ assertFailure+ "Expected an error deleting from missing queue, got success",+ testCase "connection error surfaces PgmqConnectionError" $ do+ let badCfg =+ PoolConfig.settings+ [ PoolConfig.size 1,+ PoolConfig.staticConnectionSettings+ "host=127.0.0.1 port=1 user=nobody dbname=nonexistent connect_timeout=1"+ ]+ badPool <- Pool.acquire badCfg+ result <-+ runEff+ . runError @PgmqRuntimeError+ . runPgmq badPool+ $ listQueues+ Pool.release badPool+ case result of+ Left (_cs, PgmqConnectionError _) -> pure ()+ Left (_cs, other) ->+ assertFailure $+ "Expected PgmqConnectionError, got " <> show other+ Right _ ->+ assertFailure+ "Expected a connection error, got success"+ ]
+ test/TracedInterpreterSpec.hs view
@@ -0,0 +1,311 @@+{-# LANGUAGE OverloadedStrings #-}++module TracedInterpreterSpec (tests) where++import Control.Monad (unless)+import Control.Monad.IO.Class (liftIO)+import Data.HashMap.Strict qualified as HM+import Data.IORef (IORef, readIORef)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Effectful (runEff)+import Effectful.Error.Static (runError)+import Hasql.Pool qualified as Pool+import OpenTelemetry.Attributes qualified as Attrs+import OpenTelemetry.Context qualified as Ctxt+import OpenTelemetry.Context.ThreadLocal qualified as CtxtLocal+import OpenTelemetry.Exporter.InMemory.Span qualified as InMemoryExporter+import OpenTelemetry.Propagator (extract)+import OpenTelemetry.Propagator.W3CTraceContext qualified as W3C+import OpenTelemetry.Trace.Core qualified as OTel+import OpenTelemetry.Trace.Id.Generator.Default (defaultIdGenerator)+import OpenTelemetry.Util (appendOnlyBoundedCollectionValues)+import Pgmq.Effectful+ ( MessageBody (..),+ MessageId (..),+ MessageQuery (..),+ PgmqRuntimeError (..),+ QueueName,+ ReadMessage (..),+ SendMessage (..),+ createQueue,+ deleteMessage,+ parseQueueName,+ queueNameToText,+ readMessage,+ runPgmqTraced,+ sendMessage,+ sendMessageTraced,+ )+import Pgmq.Effectful.Telemetry (jsonToTraceHeaders)+import Pgmq.Types qualified as Pgmq+import System.Random (randomRIO)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++tests :: Pool.Pool -> TestTree+tests pool =+ testGroup+ "Traced interpreter"+ [ testGroup+ "Error propagation"+ [ testCase "statement error surfaces PgmqSessionError via Error channel" $ do+ (tracer, _provider, _spansRef) <- setupTracer+ bogus <- mkQueueName "queue_that_does_not_exist_xyz2"+ result <-+ runEff+ . runError @PgmqRuntimeError+ . runPgmqTraced pool tracer+ $ deleteMessage+ MessageQuery+ { queueName = bogus,+ messageId = MessageId 1+ }+ case result of+ Left (_cs, PgmqSessionError _) -> pure ()+ Left (_cs, other) ->+ assertFailure $+ "Expected PgmqSessionError, got " <> show other+ Right _ ->+ assertFailure+ "Expected an error, got success"+ ],+ testGroup+ "v1.24 semantic conventions"+ [ testCase "publish emits messaging.* + db.* attributes" $ do+ (tracer, _provider, spansRef) <- setupTracer+ queue <- mkUniqueQueue "publish_attrs"+ result <-+ runEff . runError @PgmqRuntimeError . runPgmqTraced pool tracer $ do+ createQueue queue+ sendMessage+ SendMessage+ { queueName = queue,+ messageBody = MessageBody "hello",+ delay = Nothing+ }+ _ <- assertRight result+ spans <- readIORef spansRef+ let publishSpans = filter ((==) "publish" . firstWord . OTel.spanName) spans+ unless (length publishSpans == 1) $+ assertFailure $+ "Expected exactly one publish span, got "+ <> show (map OTel.spanName spans)+ s <- singleSpan publishSpans "publish"+ assertEqual+ "span name carries destination"+ ("publish " <> queueNameToText queue)+ (OTel.spanName s)+ assertSpanKindProducer s+ assertAttrText s "messaging.system" "pgmq"+ assertAttrText s "messaging.operation" "publish"+ assertAttrText s "messaging.destination.name" (queueNameToText queue)+ assertAttrText s "db.system" "postgresql"+ assertAttrText s "db.operation" "pgmq.send",+ testCase "receive emits messaging.* + db.* attributes" $ do+ (tracer, _provider, spansRef) <- setupTracer+ queue <- mkUniqueQueue "receive_attrs"+ result <-+ runEff . runError @PgmqRuntimeError . runPgmqTraced pool tracer $ do+ createQueue queue+ _ <-+ sendMessage+ SendMessage+ { queueName = queue,+ messageBody = MessageBody "one",+ delay = Nothing+ }+ readMessage+ ReadMessage+ { queueName = queue,+ delay = 30,+ batchSize = Just 1,+ conditional = Nothing+ }+ _ <- assertRight result+ spans <- readIORef spansRef+ let receiveSpans = filter ((==) "receive" . firstWord . OTel.spanName) spans+ unless (length receiveSpans == 1) $+ assertFailure $+ "Expected exactly one receive span, got "+ <> show (map OTel.spanName spans)+ s <- singleSpan receiveSpans "receive"+ assertEqual+ "span name carries destination"+ ("receive " <> queueNameToText queue)+ (OTel.spanName s)+ assertSpanKindConsumer s+ assertAttrText s "messaging.system" "pgmq"+ assertAttrText s "messaging.operation" "receive"+ assertAttrText s "messaging.destination.name" (queueNameToText queue)+ assertAttrText s "db.system" "postgresql"+ assertAttrText s "db.operation" "pgmq.read",+ testCase "error path records exception event and Error status" $ do+ (tracer, _provider, spansRef) <- setupTracer+ bogus <- mkQueueName "queue_that_does_not_exist_xyz3"+ _ <-+ runEff+ . runError @PgmqRuntimeError+ . runPgmqTraced pool tracer+ $ deleteMessage+ MessageQuery {queueName = bogus, messageId = MessageId 1}+ spans <- readIORef spansRef+ s <- case spans of+ [only] -> pure only+ _ ->+ assertFailure+ ( "Expected exactly one span, got "+ <> show (map OTel.spanName spans)+ )+ >> fail ""+ let events =+ V.toList+ (appendOnlyBoundedCollectionValues (OTel.spanEvents s))+ hasExceptionEvent =+ any ((==) "exception" . OTel.eventName) events+ assertBool "span has exception event" hasExceptionEvent+ case OTel.spanStatus s of+ OTel.Error desc ->+ assertBool+ ( "span status description should be a short non-PII label, got "+ <> T.unpack desc+ )+ ("pool.session.statement" `T.isPrefixOf` desc)+ other ->+ assertFailure $ "expected Error status, got " <> show other,+ testCase "W3C traceparent round-trips through message headers" $ do+ (tracer, provider, _spansRef) <- setupTracer+ queue <- mkUniqueQueue "w3c_roundtrip"+ parentSpan <-+ OTel.createSpan+ tracer+ Ctxt.empty+ "parent"+ OTel.defaultSpanArguments+ parentTraceId <- OTel.traceId <$> OTel.getSpanContext parentSpan+ _ <- CtxtLocal.attachContext (Ctxt.insertSpan parentSpan Ctxt.empty)+ result <-+ runEff . runError @PgmqRuntimeError . runPgmqTraced pool tracer $ do+ createQueue queue+ _ <- sendMessageTraced provider queue (MessageBody "payload") Nothing+ readMessage+ ReadMessage+ { queueName = queue,+ delay = 30,+ batchSize = Just 1,+ conditional = Nothing+ }+ OTel.endSpan parentSpan Nothing+ msgs <- assertRight result+ msg <- case V.toList msgs of+ [m] -> pure m+ other ->+ assertFailure ("expected 1 message, got " <> show (length other))+ >> fail ""+ case Pgmq.headers msg of+ Nothing ->+ assertFailure+ "expected trace headers on received message, got none"+ Just hdrs -> do+ let propagator = OTel.getTracerProviderPropagators provider+ ctx <-+ liftIO $+ extract propagator (jsonToTraceHeaders hdrs) Ctxt.empty+ case Ctxt.lookupSpan ctx of+ Nothing ->+ assertFailure $+ "propagator did not extract a span from headers: " <> show hdrs+ Just extracted -> do+ extractedTraceId <- OTel.traceId <$> OTel.getSpanContext extracted+ assertEqual+ "trace id round-trips via message headers"+ parentTraceId+ extractedTraceId+ ]+ ]++-- ---------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------++-- | Build a tracer, tracer provider (with in-memory list processor and+-- W3C Trace Context propagator), and a handle to the exported spans.+setupTracer :: IO (OTel.Tracer, OTel.TracerProvider, IORef [OTel.ImmutableSpan])+setupTracer = do+ (processor, spansRef) <- InMemoryExporter.inMemoryListExporter+ let opts =+ OTel.emptyTracerProviderOptions+ { OTel.tracerProviderOptionsIdGenerator = defaultIdGenerator,+ OTel.tracerProviderOptionsPropagators = W3C.w3cTraceContextPropagator+ }+ provider <- OTel.createTracerProvider [processor] opts+ let tracer =+ OTel.makeTracer provider "pgmq-effectful-test" OTel.tracerOptions+ pure (tracer, provider, spansRef)++mkQueueName :: String -> IO QueueName+mkQueueName raw =+ case parseQueueName (T.pack raw) of+ Right q -> pure q+ Left err ->+ assertFailure+ ("could not build queue name " <> show raw <> ": " <> show err)+ >> fail ""++mkUniqueQueue :: String -> IO QueueName+mkUniqueQueue prefix = do+ suffix <- randomRIO (100000 :: Int, 999999)+ mkQueueName (prefix <> "_" <> show suffix)++firstWord :: Text -> Text+firstWord = T.takeWhile (/= ' ')++assertRight :: (Show a) => Either a b -> IO b+assertRight = \case+ Right b -> pure b+ Left e ->+ assertFailure ("expected Right, got Left " <> show e) >> fail ""++singleSpan :: [OTel.ImmutableSpan] -> String -> IO OTel.ImmutableSpan+singleSpan spans label = case spans of+ [s] -> pure s+ _ ->+ assertFailure+ ( "expected exactly one "+ <> label+ <> " span, got "+ <> show (map OTel.spanName spans)+ )+ >> fail ""++-- | 'OTel.SpanKind' has no 'Eq' instance, so assert via pattern.+assertSpanKindProducer :: OTel.ImmutableSpan -> IO ()+assertSpanKindProducer s = case OTel.spanKind s of+ OTel.Producer -> pure ()+ other -> assertFailure ("expected Producer span kind, got " <> show other)++assertSpanKindConsumer :: OTel.ImmutableSpan -> IO ()+assertSpanKindConsumer s = case OTel.spanKind s of+ OTel.Consumer -> pure ()+ other -> assertFailure ("expected Consumer span kind, got " <> show other)++-- | Assert a span carries the given attribute as a 'TextAttribute' with+-- the given value. Produces a readable failure message with the full+-- attribute map when the assertion fails.+assertAttrText :: OTel.ImmutableSpan -> Text -> Text -> IO ()+assertAttrText s key expected = do+ let attrMap = Attrs.getAttributeMap (OTel.spanAttributes s)+ case HM.lookup key attrMap of+ Just (Attrs.AttributeValue (Attrs.TextAttribute actual)) ->+ assertEqual ("attribute " <> T.unpack key) expected actual+ other ->+ assertFailure $+ "expected text attribute "+ <> T.unpack key+ <> "="+ <> T.unpack expected+ <> "; got "+ <> show other+ <> " in "+ <> show attrMap