packages feed

shibuya-core 0.8.0.1 → 0.9.0.0

raw patch · 12 files changed

+399/−15 lines, 12 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Shibuya: ApplicationFailure :: !DeadLetterCode -> !Text -> DeadLetterReason
+ Shibuya: data DeadLetterCode
+ Shibuya: deadLetterCodeText :: DeadLetterCode -> Text
+ Shibuya: deadLetterReasonCode :: DeadLetterReason -> DeadLetterCode
+ Shibuya: deadLetterReasonDetail :: DeadLetterReason -> Maybe Text
+ Shibuya: mkDeadLetterCode :: Text -> Either Text DeadLetterCode
+ Shibuya: renderDeadLetterReason :: DeadLetterReason -> Text
+ Shibuya.Core.Ack: ApplicationFailure :: !DeadLetterCode -> !Text -> DeadLetterReason
+ Shibuya.Core.Ack: data DeadLetterCode
+ Shibuya.Core.Ack: deadLetterCodeText :: DeadLetterCode -> Text
+ Shibuya.Core.Ack: deadLetterReasonCode :: DeadLetterReason -> DeadLetterCode
+ Shibuya.Core.Ack: deadLetterReasonDetail :: DeadLetterReason -> Maybe Text
+ Shibuya.Core.Ack: instance GHC.Classes.Eq Shibuya.Core.Ack.DeadLetterCode
+ Shibuya.Core.Ack: instance GHC.Classes.Ord Shibuya.Core.Ack.DeadLetterCode
+ Shibuya.Core.Ack: instance GHC.Internal.Show.Show Shibuya.Core.Ack.DeadLetterCode
+ Shibuya.Core.Ack: mkDeadLetterCode :: Text -> Either Text DeadLetterCode
+ Shibuya.Core.Ack: renderDeadLetterReason :: DeadLetterReason -> Text
+ Shibuya.Telemetry.Semantic: attrShibuyaDeadLetterReasonCode :: Text

Files

CHANGELOG.md view
@@ -1,5 +1,26 @@ # Changelog +## 0.9.0.0 — 2026-08-10++### Breaking Changes++- `DeadLetterReason` gains `ApplicationFailure DeadLetterCode Text`. Exhaustive+  matches must handle the new constructor or migrate to the total+  `deadLetterReasonCode`, `deadLetterReasonDetail`, and+  `renderDeadLetterReason` functions. Under PVP this ships as 0.9.0.0;+  downstream `shibuya-core ^>=0.8` bounds intentionally exclude it and must be+  reviewed before widening.++### New Features++- Add opaque, startup-validated application dead-letter codes and canonical+  code/detail projections. Existing poison, invalid-payload, and retry-exhaustion+  render strings remain byte-for-byte unchanged.+- Single-message processing spans now emit+  `shibuya.dead_letter.reason.code` for dead-letter decisions and use the+  canonical rendered reason as the error status description. Human detail is+  not an attribute or metric label.+ ## 0.8.0.1 — 2026-07-04  ### Other Changes
shibuya-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.12 name: shibuya-core-version: 0.8.0.1+version: 0.9.0.0 synopsis: Supervised queue processing framework for Haskell description:   A supervised queue processing framework inspired by Broadway (Elixir).@@ -122,6 +122,7 @@     Shibuya.Core.RetrySpec     Shibuya.Core.TypesSpec     Shibuya.PolicySpec+    Shibuya.PublicApiSpec     Shibuya.Runner.BatchProcessorSpec     Shibuya.Runner.BatcherSpec     Shibuya.Runner.PartitionOrderingSpec
src/Shibuya.hs view
@@ -33,7 +33,13 @@     Handler,     AckDecision (..),     RetryDelay (..),+    DeadLetterCode,+    mkDeadLetterCode,+    deadLetterCodeText,     DeadLetterReason (..),+    deadLetterReasonCode,+    deadLetterReasonDetail,+    renderDeadLetterReason,     HaltReason (..),     ProcessorHalt (..), @@ -114,7 +120,18 @@     waitApp,   ) import Shibuya.Batch-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))+import Shibuya.Core.Ack+  ( AckDecision (..),+    DeadLetterCode,+    DeadLetterReason (..),+    HaltReason (..),+    RetryDelay (..),+    deadLetterCodeText,+    deadLetterReasonCode,+    deadLetterReasonDetail,+    mkDeadLetterCode,+    renderDeadLetterReason,+  ) import Shibuya.Core.AckHandle (AckHandle (..)) import Shibuya.Core.Error (ConfigError (..), HandlerError (..), PolicyError (..), RuntimeError (..)) import Shibuya.Core.Ingested (Ingested (..), Message (..), mkIngested, toMessage)
src/Shibuya/Core/Ack.hs view
@@ -6,7 +6,13 @@     RetryDelay (..),      -- * Dead Letter+    DeadLetterCode,+    mkDeadLetterCode,+    deadLetterCodeText,     DeadLetterReason (..),+    deadLetterReasonCode,+    deadLetterReasonDetail,+    renderDeadLetterReason,      -- * Halt     HaltReason (..),@@ -16,21 +22,121 @@   ) where +import Data.Char (isAsciiLower)+import Data.Text qualified as Text import Shibuya.Prelude  -- | Delay before retry. newtype RetryDelay = RetryDelay {unRetryDelay :: NominalDiffTime}   deriving stock (Eq, Show) +-- | A stable, machine-queryable application dead-letter identifier.+--+-- The constructor is intentionally private. Validate a finite set of codes+-- during application startup, retain the resulting values in configuration,+-- and reuse them in handlers rather than validating on every message.+newtype DeadLetterCode = DeadLetterCode Text+  deriving stock (Eq, Ord, Show)++-- | Validate an application-owned dead-letter code.+--+-- A valid code is at most 128 ASCII characters and contains at least two+-- dot-separated segments. Each segment starts with a lowercase ASCII letter+-- and then contains only lowercase ASCII letters, digits, or underscores. The+-- first segment @shibuya@ is reserved for framework-owned codes.+mkDeadLetterCode :: Text -> Either Text DeadLetterCode+mkDeadLetterCode code+  | Text.null code = invalid "must not be empty"+  | Text.length (Text.take 129 code) > 128 = invalid "must contain at most 128 ASCII characters"+  | length segments < 2 = invalid "must contain at least two dot-separated segments"+  | Just segment <- firstInvalidSegment segments =+      invalid $ Text.concat ["segment \"", segment, "\" must match [a-z][a-z0-9_]*"]+  | hasReservedFirstSegment segments = invalid "must not use the reserved first segment \"shibuya\""+  | otherwise = Right (DeadLetterCode code)+  where+    segments = Text.splitOn "." code+    invalid rule = Left $ Text.concat ["invalid dead-letter code \"", code, "\": ", rule]++-- | Unwrap a validated dead-letter code for storage, tracing, or logging.+deadLetterCodeText :: DeadLetterCode -> Text+deadLetterCodeText (DeadLetterCode code) = code+ -- | Why a message is being dead-lettered. data DeadLetterReason-  = -- | Message is permanently unprocessable+  = -- | The message is permanently unprocessable, despite retries.     PoisonPill !Text-  | -- | Message payload failed validation/parsing+  | -- | The message payload failed parsing or structural validation.     InvalidPayload !Text-  | -- | Retry limit exceeded+  | -- | The framework's retry limit was exceeded.     MaxRetriesExceeded+  | -- | A syntactically valid message was permanently rejected by+    -- application policy.+    --+    -- The application owns the stability of the code. Detail is transported+    -- verbatim for operators and must not contain secrets, unrestricted+    -- backend errors, raw SQL, or full payloads.+    ApplicationFailure !DeadLetterCode !Text   deriving stock (Eq, Show, Generic)++-- | Return the stable machine-facing code for any dead-letter reason.+deadLetterReasonCode :: DeadLetterReason -> DeadLetterCode+deadLetterReasonCode (PoisonPill _) = poisonPillCode+deadLetterReasonCode (InvalidPayload _) = invalidPayloadCode+deadLetterReasonCode MaxRetriesExceeded = maxRetriesExceededCode+deadLetterReasonCode (ApplicationFailure code _) = code++-- | Return human-facing detail when the reason carries it.+--+-- Detail is transported verbatim. Applications must keep it operationally+-- bounded and exclude secrets, raw payloads, raw SQL, and unrestricted+-- backend error text.+deadLetterReasonDetail :: DeadLetterReason -> Maybe Text+deadLetterReasonDetail (PoisonPill detail) = Just detail+deadLetterReasonDetail (InvalidPayload detail) = Just detail+deadLetterReasonDetail MaxRetriesExceeded = Nothing+deadLetterReasonDetail (ApplicationFailure _ detail) = Just detail++-- | Render a reason in Shibuya's canonical compatibility format.+--+-- Built-in strings retain their historical encoding. Adapters with+-- structured storage should prefer 'deadLetterReasonCode' and+-- 'deadLetterReasonDetail' separately.+renderDeadLetterReason :: DeadLetterReason -> Text+renderDeadLetterReason reason =+  let code = deadLetterCodeText (deadLetterReasonCode reason)+   in case deadLetterReasonDetail reason of+        Nothing -> code+        Just detail -> Text.concat [code, ": ", detail]++firstInvalidSegment :: [Text] -> Maybe Text+firstInvalidSegment [] = Nothing+firstInvalidSegment (segment : rest)+  | validSegment segment = firstInvalidSegment rest+  | otherwise = Just segment++validSegment :: Text -> Bool+validSegment segment =+  case Text.uncons segment of+    Nothing -> False+    Just (first, suffix) ->+      isAsciiLower first && Text.all validSegmentSuffix suffix++validSegmentSuffix :: Char -> Bool+validSegmentSuffix char =+  isAsciiLower char || ('0' <= char && char <= '9') || char == '_'++hasReservedFirstSegment :: [Text] -> Bool+hasReservedFirstSegment (first : _) = first == "shibuya"+hasReservedFirstSegment [] = False++poisonPillCode :: DeadLetterCode+poisonPillCode = DeadLetterCode "poison_pill"++invalidPayloadCode :: DeadLetterCode+invalidPayloadCode = DeadLetterCode "invalid_payload"++maxRetriesExceededCode :: DeadLetterCode+maxRetriesExceededCode = DeadLetterCode "max_retries_exceeded"  -- | Why processing should halt. data HaltReason
src/Shibuya/Internal/Runner/Supervised.hs view
@@ -51,7 +51,14 @@ import OpenTelemetry.Trace.Core qualified as OTel import Shibuya.Adapter (Adapter (..)) import Shibuya.Batch (BatchConfig, BatchHandler)-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))+import Shibuya.Core.Ack+  ( AckDecision (..),+    HaltReason (..),+    RetryDelay (..),+    deadLetterCodeText,+    deadLetterReasonCode,+    renderDeadLetterReason,+  ) import Shibuya.Core.Error (HandlerError (..), handlerErrorToText) import Shibuya.Core.Ingested (Ingested (..), toMessage) import Shibuya.Core.Metrics@@ -95,6 +102,7 @@     attrMessagingOperation,     attrMessagingSystem,     attrShibuyaAckDecision,+    attrShibuyaDeadLetterReasonCode,     attrShibuyaInflightCount,     attrShibuyaInflightMax,     attrShibuyaPartition,@@ -642,8 +650,12 @@             case decision' of               AckOk -> setStatus traceSpan OTel.Ok               AckRetry _ -> setStatus traceSpan OTel.Ok-              AckDeadLetter reason ->-                setStatus traceSpan $ OTel.Error $ showDeadLetterReason reason+              AckDeadLetter reason -> do+                addAttribute+                  traceSpan+                  attrShibuyaDeadLetterReasonCode+                  (deadLetterCodeText (deadLetterReasonCode reason))+                setStatus traceSpan $ OTel.Error $ renderDeadLetterReason reason               AckHalt reason ->                 setStatus traceSpan $ OTel.Error $ showHaltReason reason           Left err -> do@@ -680,11 +692,6 @@     showAckDecision (AckRetry _) = "ack_retry"     showAckDecision (AckDeadLetter _) = "ack_dead_letter"     showAckDecision (AckHalt _) = "ack_halt"--    showDeadLetterReason :: DeadLetterReason -> Text-    showDeadLetterReason (PoisonPill t) = "poison_pill: " <> t-    showDeadLetterReason (InvalidPayload t) = "invalid_payload: " <> t-    showDeadLetterReason MaxRetriesExceeded = "max_retries_exceeded"      showHaltReason :: HaltReason -> Text     showHaltReason (HaltOrderedStream t) = "halt_ordered_stream: " <> t
src/Shibuya/Telemetry/Semantic.hs view
@@ -21,6 +21,7 @@     attrShibuyaInflightCount,     attrShibuyaInflightMax,     attrShibuyaAckDecision,+    attrShibuyaDeadLetterReasonCode,     attrShibuyaPartition,     attrShibuyaBatchKey,     attrShibuyaBatchSize,@@ -131,6 +132,14 @@ -- Shibuya-specific: there is no upstream key for an explicit ack decision. attrShibuyaAckDecision :: Text attrShibuyaAckDecision = "shibuya.ack.decision"++-- | The stable dead-letter reason code+-- (@shibuya.dead_letter.reason.code@).+--+-- Shibuya-specific: codes are bounded, machine-queryable identifiers. The+-- potentially high-cardinality human detail is deliberately excluded.+attrShibuyaDeadLetterReasonCode :: Text+attrShibuyaDeadLetterReasonCode = "shibuya.dead_letter.reason.code"  -- | A generic partition identifier (@shibuya.partition@). --
test/Main.hs view
@@ -10,6 +10,7 @@ import Shibuya.Core.RetrySpec qualified import Shibuya.Core.TypesSpec qualified import Shibuya.PolicySpec qualified+import Shibuya.PublicApiSpec qualified import Shibuya.Runner.BatchProcessorSpec qualified import Shibuya.Runner.BatcherSpec qualified import Shibuya.Runner.PartitionOrderingSpec qualified@@ -30,6 +31,7 @@   describe "Shibuya.Core.Ack" Shibuya.Core.AckSpec.spec   describe "Shibuya.Core.Retry" Shibuya.Core.RetrySpec.spec   describe "Shibuya.Policy" Shibuya.PolicySpec.spec+  Shibuya.PublicApiSpec.spec   describe "Shibuya.Runner" Shibuya.RunnerSpec.spec   Shibuya.Runner.BatcherSpec.spec   Shibuya.Runner.BatchProcessorSpec.spec
test/Shibuya/Core/AckSpec.hs view
@@ -2,6 +2,7 @@  module Shibuya.Core.AckSpec (spec) where +import Data.Text qualified as Text import Data.Time (secondsToNominalDiffTime) import Shibuya.Core.Ack import Test.Hspec@@ -25,9 +26,12 @@       let r1 = PoisonPill "bad message"           r2 = InvalidPayload "parse error"           r3 = MaxRetriesExceeded+          code = validCode "example.policy.rejected"+          r4 = ApplicationFailure code "policy rejected"       r1 `shouldNotBe` r2       r2 `shouldNotBe` r3       r1 `shouldNotBe` r3+      r3 `shouldNotBe` r4      it "PoisonPill carries message" $ do       let r = PoisonPill "corrupt data"@@ -41,6 +45,59 @@         InvalidPayload msg -> msg `shouldBe` "JSON decode failed"         _ -> expectationFailure "wrong constructor" +    describe "mkDeadLetterCode" $ do+      it "accepts namespaced lowercase application codes" $ do+        fmap deadLetterCodeText (mkDeadLetterCode "keiro.router.selection.recipient_overflow")+          `shouldBe` Right "keiro.router.selection.recipient_overflow"++      it "accepts the 128-character boundary" $ do+        let code = "a." <> Text.replicate 126 "b"+        fmap deadLetterCodeText (mkDeadLetterCode code) `shouldBe` Right code++      it "rejects each invalid grammar boundary" $ do+        let invalidCodes =+              [ "",+                "unqualified",+                "keiro.Router",+                "keiro.router-selection",+                "keiro..selection",+                "1keiro.router",+                "keiro.1router",+                "keiro.routér",+                "shibuya.router",+                "a." <> Text.replicate 127 "b"+              ]+        mapM_ (\code -> mkDeadLetterCode code `shouldSatisfy` isLeft) invalidCodes++      it "identifies the rejected code and failed rule" $ do+        mkDeadLetterCode "Keiro.router"+          `shouldBe` Left "invalid dead-letter code \"Keiro.router\": segment \"Keiro\" must match [a-z][a-z0-9_]*"++    describe "dead-letter projections and rendering" $ do+      it "preserves the built-in contracts exactly" $ do+        let cases =+              [ (PoisonPill "x", "poison_pill", Just "x", "poison_pill: x"),+                (InvalidPayload "x", "invalid_payload", Just "x", "invalid_payload: x"),+                (MaxRetriesExceeded, "max_retries_exceeded", Nothing, "max_retries_exceeded")+              ]+        mapM_+          ( \(reason, code, detail, rendered) -> do+              deadLetterCodeText (deadLetterReasonCode reason) `shouldBe` code+              deadLetterReasonDetail reason `shouldBe` detail+              renderDeadLetterReason reason `shouldBe` rendered+          )+          cases++      it "preserves an application code and detail" $ do+        let code = validCode "keiro.router.selection.recipient_overflow"+            reason = ApplicationFailure code "selected 101 recipients; configured limit is 100"+        deadLetterCodeText (deadLetterReasonCode reason)+          `shouldBe` "keiro.router.selection.recipient_overflow"+        deadLetterReasonDetail reason+          `shouldBe` Just "selected 101 recipients; configured limit is 100"+        renderDeadLetterReason reason+          `shouldBe` "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"+   describe "HaltReason" $ do     it "HaltOrderedStream carries message" $ do       let r = HaltOrderedStream "ordering violation"@@ -88,3 +145,13 @@       case decision of         AckHalt r -> r `shouldBe` reason         _ -> expectationFailure "wrong constructor"++isLeft :: Either a b -> Bool+isLeft (Left _) = True+isLeft (Right _) = False++validCode :: Text.Text -> DeadLetterCode+validCode code =+  case mkDeadLetterCode code of+    Left err -> error $ "invalid test fixture: " <> show err+    Right valid -> valid
+ test/Shibuya/PublicApiSpec.hs view
@@ -0,0 +1,31 @@+module Shibuya.PublicApiSpec (spec) where++import Shibuya+import Test.Hspec++spec :: Spec+spec =+  describe "Shibuya.PublicApi" $ do+    it "constructs and projects an application-defined dead-letter reason" $ do+      case mkDeadLetterCode "keiro.router.selection.recipient_overflow" of+        Left err -> expectationFailure $ "valid public code was rejected: " <> show err+        Right code -> do+          let _handler = mkRouterHandler code+              reason = routerReason code+          deadLetterCodeText (deadLetterReasonCode reason)+            `shouldBe` "keiro.router.selection.recipient_overflow"+          deadLetterReasonDetail reason+            `shouldBe` Just "selected 101 recipients; configured limit is 100"+          renderDeadLetterReason reason+            `shouldBe` "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"++mkRouterHandler :: DeadLetterCode -> Handler es RouterMessage+mkRouterHandler code _message = pure $ AckDeadLetter $ routerReason code++routerReason :: DeadLetterCode -> DeadLetterReason+routerReason code =+  ApplicationFailure+    code+    "selected 101 recipients; configured limit is 100"++type RouterMessage = ()
test/Shibuya/Runner/BatchProcessorSpec.hs view
@@ -35,9 +35,11 @@   ) import Shibuya.Core.Ack   ( AckDecision (..),+    DeadLetterCode,     DeadLetterReason (..),     HaltReason (..),     RetryDelay (..),+    mkDeadLetterCode,   ) import Shibuya.Core.AckHandle (AckHandle (..)) import Shibuya.Core.Ingested (Ingested, Message (..), mkIngested)@@ -109,6 +111,34 @@       -- Exactly one successful finalization despite two prior throws.       tracked `shouldBe` [(MessageId "flaky-1", AckOk)] +    it "preserves application failures in explicit and fallback decisions" $ do+      let code = validDeadLetterCode "keiro.router.selection.recipient_overflow"+          fallbackReason = ApplicationFailure code "fallback policy rejection"+          explicitReason = ApplicationFailure code "explicit policy rejection"+      (tracked, metrics) <- runEff $ runTracingNoop $ do+        tracking <- newTrackingAck+        batch <- buildBatch tracking 5 TriggerSize+        let handler _info _msgs =+              pure $+                withFallback+                  (AckDeadLetter fallbackReason)+                  [ (MessageId "msg-2", AckDeadLetter explicitReason),+                    (MessageId "msg-4", AckOk)+                  ]+        m <- runBatchesWithMetrics (ProcessorId "m1-application-failure") Serial handler [batch]+        t <- getTrackedDecisions tracking+        pure (t, m)++      sort (map fst tracked) `shouldBe` expectedIds+      lookup (MessageId "msg-1") tracked `shouldBe` Just (AckDeadLetter fallbackReason)+      lookup (MessageId "msg-2") tracked `shouldBe` Just (AckDeadLetter explicitReason)+      lookup (MessageId "msg-3") tracked `shouldBe` Just (AckDeadLetter fallbackReason)+      lookup (MessageId "msg-4") tracked `shouldBe` Just AckOk+      lookup (MessageId "msg-5") tracked `shouldBe` Just (AckDeadLetter fallbackReason)+      metrics.batch.partialFailures `shouldBe` 1+      metrics.stats.processed `shouldBe` 1+      metrics.stats.failed `shouldBe` 4+   describe "exception fallback (M2)" $ do     it "finalizes all 5 with AckRetry when the handler throws" $ do       (tracked, metrics) <- runEff $ runTracingNoop $ do@@ -321,3 +351,9 @@  tshow :: (Show a) => a -> Text tshow = Text.pack . show++validDeadLetterCode :: Text -> DeadLetterCode+validDeadLetterCode code =+  case mkDeadLetterCode code of+    Left err -> error $ "invalid test fixture: " <> show err+    Right valid -> valid
test/Shibuya/Runner/SupervisedSpec.hs view
@@ -27,7 +27,14 @@     stopAppGracefully,   ) import Shibuya.Batch.TestHarness (finalizedExactlyOnce)-import Shibuya.Core.Ack (AckDecision (..), HaltReason (..), RetryDelay (..))+import Shibuya.Core.Ack+  ( AckDecision (..),+    DeadLetterCode,+    DeadLetterReason (..),+    HaltReason (..),+    RetryDelay (..),+    mkDeadLetterCode,+  ) import Shibuya.Core.AckHandle (AckHandle (..)) import Shibuya.Core.Ingested (Ingested, Message (..), mkIngested) import Shibuya.Core.Metrics@@ -500,6 +507,29 @@           metrics.stats.processed `shouldBe` 3           metrics.stats.failed `shouldBe` 2 +        it "preserves an application failure through finalization" $ do+          let code = validDeadLetterCode "keiro.router.selection.recipient_overflow"+              reason =+                ApplicationFailure+                  code+                  "selected 101 recipients; configured limit is 100"+              decision = AckDeadLetter reason+          (metrics, tracked) <- runEff $ runTracingNoop $ do+            tracking <- newTrackingAck+            let adapter = trackedListAdapter tracking [createTestEnvelope 1]+                handler _ = pure decision+            sp <- runWithMetrics 1 (ProcessorId "application-failure-finalize") adapter handler+            decisions <- getTrackedDecisions tracking+            finalMetrics <- getMetrics sp+            pure (finalMetrics, decisions)++          finalizedExactlyOnce+            tracked+            (Map.singleton (MessageId "msg-1") decision)+            `shouldBe` Right ()+          metrics.stats.processed `shouldBe` 0+          metrics.stats.failed `shouldBe` 1+         it "retries transient finalizer failures on the single-message path" $ do           (attempts, tracked) <- runEff $ runTracingNoop $ do             attemptsRef <- liftIO $ newIORef (0 :: Int)@@ -1123,6 +1153,12 @@       source = Stream.fromList messages,       shutdown = pure ()     }++validDeadLetterCode :: Text.Text -> DeadLetterCode+validDeadLetterCode code =+  case mkDeadLetterCode code of+    Left err -> error $ "invalid test fixture: " <> show err+    Right valid -> valid  testHandler :: (IOE :> es) => IORef [String] -> Handler es String testHandler ref ingested = do
test/Shibuya/Telemetry/SemanticSpec.hs view
@@ -29,28 +29,40 @@   ( Event (..),     ImmutableSpan (..),     InstrumentationLibrary (..),+    SpanStatus (..),     createTracerProvider,     emptyTracerProviderOptions,     hotAttributes,     hotEvents,     hotName,+    hotStatus,     makeTracer,     shutdownTracerProvider,     tracerOptions,   ) import OpenTelemetry.Util (appendOnlyBoundedCollectionValues) import Shibuya.Adapter.Mock (listAdapter)-import Shibuya.Core.Ack (AckDecision (..))+import Shibuya.Core.Ack+  ( AckDecision (..),+    DeadLetterCode,+    DeadLetterReason (..),+    mkDeadLetterCode,+  ) import Shibuya.Core.AckHandle (AckHandle (..)) import Shibuya.Core.Ingested (mkIngested) import Shibuya.Core.Metrics (ProcessorId (..)) import Shibuya.Core.Types (Envelope (..), MessageId (..), mkEnvelope) import Shibuya.Internal.Runner.Supervised (runWithMetrics) import Shibuya.Telemetry.Effect (runTracing)+import Shibuya.Telemetry.Semantic (attrShibuyaDeadLetterReasonCode) import Test.Hspec  spec :: Spec spec = describe "Shibuya.Telemetry.Semantic (wire-format)" $ do+  it "keeps the application dead-letter reason code wire key stable" $ do+    attrShibuyaDeadLetterReasonCode+      `shouldBe` "shibuya.dead_letter.reason.code"+   it "emits a process span with conventions-aligned attributes and events" $ do     (processor, spansRef) <- inMemoryListExporter     provider <- createTracerProvider [processor] emptyTracerProviderOptions@@ -135,6 +147,39 @@       _ ->         expectationFailure $           "expected exactly one span, got " <> show (length spans)++  it "emits an application dead-letter code and canonical error status" $ do+    (processor, spansRef) <- inMemoryListExporter+    provider <- createTracerProvider [processor] emptyTracerProviderOptions+    let tracer = mkTestTracer provider+        code = validDeadLetterCode "keiro.router.selection.recipient_overflow"+        detail = "selected 101 recipients; configured limit is 100"++    runEff $ runTracing tracer $ do+      let envelope = mkEnvelope (MessageId "router-1") ("hello" :: Text)+          ingested = mkIngested envelope (AckHandle (\_ -> pure ()))+          adapter = listAdapter [ingested]+          handler _ = pure $ AckDeadLetter $ ApplicationFailure code detail+          procId = ProcessorId "router-consumer"+      _ <- runWithMetrics 1 procId adapter handler+      pure ()++    _ <- shutdownTracerProvider provider (Just 5_000_000)+    spans <- readIORef spansRef+    case spans of+      [s] -> do+        hot <- readIORef (spanHot s)+        let attrs = getAttributeMap (hotAttributes hot)+        attrs `shouldHaveTextAttribute` ("shibuya.ack.decision", "ack_dead_letter")+        attrs+          `shouldHaveTextAttribute` ( "shibuya.dead_letter.reason.code",+                                      "keiro.router.selection.recipient_overflow"+                                    )+        hotStatus hot+          `shouldBe` Error "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"+      _ ->+        expectationFailure $+          "expected exactly one span, got " <> show (length spans)   where     mkTestTracer p =       makeTracer@@ -167,3 +212,9 @@         Nothing ->           expectationFailure $             "attribute " <> show k <> " missing; have keys " <> show (HashMap.keys attrs)++validDeadLetterCode :: Text -> DeadLetterCode+validDeadLetterCode code =+  case mkDeadLetterCode code of+    Left err -> error $ "invalid test fixture: " <> show err+    Right valid -> valid