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 +21/−0
- shibuya-core.cabal +2/−1
- src/Shibuya.hs +18/−1
- src/Shibuya/Core/Ack.hs +109/−3
- src/Shibuya/Internal/Runner/Supervised.hs +15/−8
- src/Shibuya/Telemetry/Semantic.hs +9/−0
- test/Main.hs +2/−0
- test/Shibuya/Core/AckSpec.hs +67/−0
- test/Shibuya/PublicApiSpec.hs +31/−0
- test/Shibuya/Runner/BatchProcessorSpec.hs +36/−0
- test/Shibuya/Runner/SupervisedSpec.hs +37/−1
- test/Shibuya/Telemetry/SemanticSpec.hs +52/−1
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