shikumi-trace-otel 0.1.1.1 → 0.1.2.0
raw patch · 5 files changed
+339/−29 lines, 5 filesdep +baikaidep +bytestringdep +effectfuldep ~generic-lensdep ~lensdep ~shikumi-tracePVP ok
version bump matches the API change (PVP)
Dependencies added: baikai, bytestring, effectful, shikumi, shikumi-cache, shikumi-eval, shikumi-testing
Dependency ranges changed: generic-lens, lens, shikumi-trace, shikumi-trace-otel
API changes (from Hackage documentation)
Files
- CHANGELOG.md +10/−0
- shikumi-trace-otel.cabal +17/−4
- src/Shikumi/Trace/OpenTelemetry.hs +78/−17
- test/BillingSpec.hs +219/−0
- test/Main.hs +15/−8
CHANGELOG.md view
@@ -2,6 +2,16 @@ ## Unreleased +## 0.1.2.0 — 2026-09-08++- Add the missing `bytestring` upper bound (`>=0.11 && <0.13`), matching the+ rest of the package set. `cabal check` reported it under+ `missing-upper-bounds`; the dependency was added this cycle without one.++- Raise the internal `shikumi` bound to `^>=0.4.0.0` and `shikumi-trace` to `^>=0.3.0.0`, and give the test suite's bare `shikumi`, `shikumi-cache`, `shikumi-eval`, and `shikumi-testing` dependencies explicit PVP bounds.++- Export separately scoped transport attempts and aggregate counters, including error status and canonical billing quality. Use observed-model evidence for gen_ai.response.model. Preserve existing exporter lifecycle and structural traversal.+ ## 0.1.1.1 — 2026-08-29 ### Changed
shikumi-trace-otel.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: shikumi-trace-otel-version: 0.1.1.1+version: 0.1.2.0 synopsis: OpenTelemetry export of shikumi hierarchical trace trees (EP-7) @@ -44,7 +44,9 @@ build-depends: , aeson >=2.2 && <2.3+ , baikai >=0.7.0.0 && <0.8 , base >=4.20 && <5+ , bytestring >=0.11 && <0.13 , containers >=0.6 && <0.9 , generic-lens >=2.2 && <2.4 , hs-opentelemetry-api >=1.0 && <1.1@@ -53,7 +55,8 @@ , hs-opentelemetry-semantic-conventions >=1.40 && <2 , lens ^>=5.3 , scientific >=0.3 && <0.4- , shikumi-trace ^>=0.2.0.0+ , shikumi ^>=0.4.0.0+ , shikumi-trace ^>=0.3.0.0 , text ^>=2.1 , time >=1.12 && <1.17 , unordered-containers >=0.2 && <0.3@@ -63,16 +66,26 @@ type: exitcode-stdio-1.0 hs-source-dirs: test main-is: Main.hs+ other-modules: BillingSpec ghc-options: -threaded -with-rtsopts=-N build-depends: , aeson+ , baikai , base+ , bytestring , containers+ , effectful+ , generic-lens , hs-opentelemetry-api >=1.0 && <1.1 , hs-opentelemetry-exporter-in-memory >=1.0 && <1.1 , hs-opentelemetry-sdk >=1.0 && <1.1- , shikumi-trace ^>=0.2.0.0- , shikumi-trace-otel ^>=0.1.1.0+ , lens+ , shikumi ^>=0.4.0.0+ , shikumi-cache ^>=0.2.0.0+ , shikumi-eval ^>=0.3.0.0+ , shikumi-testing ^>=0.1.0.0+ , shikumi-trace ^>=0.3.0.0+ , shikumi-trace-otel ^>=0.1.2.0 , tasty , tasty-hunit , text
src/Shikumi/Trace/OpenTelemetry.hs view
@@ -13,8 +13,7 @@ -- (@gen_ai.provider.name@, @gen_ai.request.model@, @gen_ai.response.model@, -- @gen_ai.usage.input_tokens@, @gen_ai.usage.output_tokens@, -- @gen_ai.operation.name@). The response model is read from the recorded--- response's echoed @model.modelId@ and omitted when no response model is--- present. Every span carries @shikumi.@-prefixed attributes+-- observed-model evidence and omitted when no such evidence is present. Every span carries @shikumi.@-prefixed attributes -- (@shikumi.span_kind@, @shikumi.retries@, optional @shikumi.incomplete@, and, -- on LM-call spans, @shikumi.cost.usd@ / @shikumi.latency_ms@). module Shikumi.Trace.OpenTelemetry@@ -22,11 +21,14 @@ ) where +import Baikai.Cost qualified as C+import Baikai.Usage qualified as U import Control.Lens ((^.))-import Control.Monad (foldM)+import Control.Monad (foldM, forM_) import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.Aeson (Value (..))+import Data.Aeson (ToJSON, Value (..), encode) import Data.Aeson.KeyMap qualified as KM+import Data.ByteString.Lazy qualified as BL import Data.Functor (($>)) import Data.Generics.Labels () import Data.HashMap.Strict (HashMap)@@ -37,7 +39,8 @@ import Data.Scientific qualified as Sci import Data.Set qualified as Set import Data.Text (Text)-import Data.Time (UTCTime)+import Data.Text.Encoding qualified as TE+import Data.Time (UTCTime, getCurrentTime) import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) import Data.Word (Word64) import OpenTelemetry.Attributes qualified as Attr@@ -46,6 +49,7 @@ import OpenTelemetry.Context qualified as Context import OpenTelemetry.SemanticConventions qualified as SC import OpenTelemetry.Trace.Core qualified as Otel+import Shikumi.LLM.Observation qualified as B import Shikumi.Trace (Span, SpanAttrs, SpanKind (..), TraceTree, childrenOf) import Shikumi.Trace.Node (renderNodePath) @@ -53,7 +57,9 @@ -- structural ('ProgramSpan' / 'ModuleSpan' / 'CombinatorSpan') and LM-call -- ('LlmCallSpan') nodes alike become spans; nesting is preserved. exportTree :: (MonadIO m) => Otel.Tracer -> TraceTree -> m ()-exportTree tracer tree = liftIO (go Set.empty Context.empty (tree ^. #root) $> ())+exportTree tracer tree = liftIO $ do+ go Set.empty Context.empty (tree ^. #root) $> ()+ forM_ (tree ^. #transportBilling) (exportBilling tracer) where smap = tree ^. #spans go visited ctx sid@@ -104,12 +110,12 @@ genAiAttrs a m0 = maybe id (AttrMap.insertByKey SC.genAi_provider_name) (a ^. #provider) $ maybe id (AttrMap.insertByKey SC.genAi_request_model) (a ^. #model) $- maybe id (AttrMap.insertByKey SC.genAi_response_model) (responseModelOf a) $+ maybe id (AttrMap.insertByKey SC.genAi_response_model) (a ^. #observedModel) $ maybe id (\n -> AttrMap.insertByKey SC.genAi_usage_inputTokens (fromIntegral n :: Int64)) (a ^. #inputTokens) $ maybe id (\n -> AttrMap.insertByKey SC.genAi_usage_outputTokens (fromIntegral n :: Int64)) (a ^. #outputTokens) $ maybe id (\u -> HashMap.insert "shikumi.cost.usd" (Attr.toAttribute (Sci.toRealFloat u :: Double))) (a ^. #costUsd) $ maybe id (\l -> HashMap.insert "shikumi.latency_ms" (Attr.toAttribute (fromIntegral l :: Int))) (a ^. #latencyMs) $- AttrMap.insertByKey SC.genAi_operation_name ("chat" :: Text) m0+ AttrMap.insertByKey SC.genAi_operation_name ("chat" :: Text) (qualityAttrs (a ^. #billingQuality) (HashMap.insert "shikumi.accounting.scope" (Attr.toAttribute ("logical-call" :: Text)) m0)) -- | The end instant to export. A span that never closed is exported with its -- own start time rather than the wall clock at export time; 'attrsFor' marks@@ -140,15 +146,70 @@ messageErr _ = False isErrorResponse _ = False --- | The model the provider says actually answered, read from the recorded--- response's echoed @model.modelId@. 'Nothing' when no response was recorded--- or the field is absent.-responseModelOf :: SpanAttrs -> Maybe Text-responseModelOf a = do- Object o <- a ^. #response- Object m <- KM.lookup "model" o- String mid <- KM.lookup "modelId" m- pure mid+-- | Canonical provider basis/availability JSON; absent metadata stays absent.+qualityAttrs :: Maybe B.UsageRecord -> HashMap Text Attr.Attribute -> HashMap Text Attr.Attribute+qualityAttrs Nothing m = m+qualityAttrs (Just (B.UsageRecord u)) m =+ maybe id (\b -> HashMap.insert "shikumi.cost.basis" (jsonAttribute b)) (C.nonEmptyBasis (U.cost u)) $+ maybe id (\a -> HashMap.insert "shikumi.usage.availability" (jsonAttribute a)) (U.availability u) m++jsonAttribute :: (ToJSON a) => a -> Attr.Attribute+jsonAttribute = Attr.toAttribute . TE.decodeUtf8 . BL.toStrict . encode++-- | Explicitly separate transport spans. Their correlation is callId/attempt;+-- no structural program parent or replay response is manufactured.+exportBilling :: Otel.Tracer -> B.BillingSummary -> IO ()+exportBilling tracer summary = do+ now <- getCurrentTime+ sp <-+ Otel.createSpan+ tracer+ Context.empty+ "transport billing summary"+ Otel.defaultSpanArguments+ { Otel.startTime = Just (utcToTimestamp now)+ }+ Otel.addAttributes+ sp+ ( HashMap.fromList+ [ ("shikumi.accounting.scope", Attr.toAttribute ("transport-summary" :: Text)),+ ("shikumi.billing.completed_attempts", Attr.toAttribute (B.completedAttempts summary)),+ ("shikumi.billing.failed_attempts", Attr.toAttribute (B.failedAttempts summary)),+ ("shikumi.billing.unknown_usage_attempts", Attr.toAttribute (B.unknownUsageAttempts summary)),+ ("shikumi.billing.detail_truncated", Attr.toAttribute (B.detailTruncated summary))+ ]+ )+ Otel.endSpan sp (Just (utcToTimestamp now))+ forM_ (B.retainedAttempts summary) $ \o -> do+ attemptSpan <-+ Otel.createSpan+ tracer+ Context.empty+ "LLM transport attempt"+ Otel.defaultSpanArguments+ { Otel.kind = Otel.Client,+ Otel.startTime = Just (utcToTimestamp (B.startedAt o))+ }+ let base =+ HashMap.fromList+ [ ("shikumi.accounting.scope", Attr.toAttribute ("transport-attempt" :: Text)),+ ("shikumi.call_id", Attr.toAttribute (B.callId o)),+ ("shikumi.attempt", Attr.toAttribute (B.attempt o)),+ ("shikumi.call_kind", jsonAttribute (B.callKind o)),+ ("gen_ai.request.model", Attr.toAttribute (B.requestedModel o)),+ ("gen_ai.provider.name", Attr.toAttribute (B.requestedProvider o)),+ ("shikumi.billing.unknown_usage", Attr.toAttribute (B.usageUnknown (B.usage o)))+ ]+ observed = maybe id (HashMap.insert "gen_ai.response.model" . Attr.toAttribute) (B.observedModel o)+ numbers = case B.usage o of+ Nothing -> id+ Just (B.UsageRecord u) ->+ HashMap.insert "gen_ai.usage.input_tokens" (Attr.toAttribute (fromIntegral (U.inputTokens u) :: Int64))+ . HashMap.insert "gen_ai.usage.output_tokens" (Attr.toAttribute (fromIntegral (U.outputTokens u) :: Int64))+ . HashMap.insert "shikumi.cost.usd" (Attr.toAttribute (fromRational (C.usd (U.cost u)) :: Double))+ Otel.addAttributes attemptSpan (observed (numbers (qualityAttrs (B.usage o) base)))+ Otel.setStatus attemptSpan (maybe Otel.Ok Otel.Error (B.terminalError o))+ Otel.endSpan attemptSpan (Just (utcToTimestamp (B.endedAt o))) kindText :: SpanKind -> Text kindText = \case
+ test/BillingSpec.hs view
@@ -0,0 +1,219 @@+-- | Full offline evaluation through actual transport, runtime, cache and export.+module BillingSpec (tests) where++import Baikai (Response, StopReason (ErrorReason), emptyContext, emptyOptions)+import Baikai.Cost qualified as C+import Baikai.Error qualified as BE+import Baikai.Evidence qualified as E+import Baikai.Usage qualified as U+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (eitherDecode, encode)+import Data.Generics.Labels ()+import Data.HashMap.Strict qualified as HM+import Data.IORef (readIORef)+import Data.Map.Strict qualified as Map+import Data.Ratio ((%))+import Data.Set qualified as Set+import Data.Text qualified as T+import Effectful (liftIO, runEff)+import Effectful.Concurrent (runConcurrent)+import Effectful.Concurrent.Async (mapConcurrently)+import Effectful.Error.Static (runErrorNoCallStack)+import Effectful.Prim (runPrim)+import OpenTelemetry.Attributes qualified as Attr+import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)+import OpenTelemetry.Trace.Core qualified as Otel+import Shikumi.Cache (cachedLLM)+import Shikumi.Cache.Backend.Memory (newMemoryCache, runCacheMemory)+import Shikumi.Effect.Time (runTime)+import Shikumi.Error (ShikumiError)+import Shikumi.Eval.Evaluate (evaluateWith)+import Shikumi.Eval.Report qualified as R+import Shikumi.Eval.Types (Example (..), dataset, scoreOne)+import Shikumi.LLM qualified as L+import Shikumi.LLM.Defaults+import Shikumi.LLM.Observation qualified as B+import Shikumi.Routing (routeLLM, runRouting)+import Shikumi.Testing.Fixtures+import Shikumi.Testing.Transport+import Shikumi.Trace qualified as Tr+import Shikumi.Trace.LiveExport (exportTreeWith)+import Shikumi.Trace.Store (replayIndex)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++-- An estimate followed by provider-reported zero must retain the estimate.+knownUsage :: Rational -> C.CostBasis -> U.Usage+knownUsage amount basis =+ U.zeroUsage+ & #inputTokens .~ 10+ & #totalTokens .~ 10+ & #availability .~ Just (U.UsageAvailability Set.empty False Set.empty)+ & #cost . #usd .~ amount+ & #cost . #basis .~ basis++responseWith :: U.Usage -> Response+responseWith u = validAnswerResponse & #message . #usage .~ u++fixtureEvidence :: E.ModelCallEvidence+fixtureEvidence =+ E.baseEvidence+ (E.evidenceRequest "fixture")+ "fixture-call"+ (E.EndpointIdentity "fixture" "custom" E.TransportHttpApi Nothing "0.7.0.0" Nothing)+ "requested-model"+ E.noThinkingRequested+ instant+ instant+ E.CallSucceeded+ "digest"+ "config"+ & #observedModel .~ E.Observed "observed-model"+ where+ instant = read "2026-09-08 00:00:00 UTC"++tests :: TestTree+tests =+ testGroup+ "EP-61 transport billing"+ [ testCase "evaluation retry, reported zero, missing usage and cache hit stay separate" $ do+ let estimated = C.standardCostBasis <> C.CostBasis Set.empty (Set.singleton C.PricingUnavailable)+ failure =+ responseWith (knownUsage (1 % 100) estimated)+ & #message . #stopReason .~ ErrorReason+ & #errorInfo .~ Just ((BE.providerError "private provider output") {BE.category = BE.TransientError})+ success =+ responseWith (knownUsage (2 % 100) C.providerReportedBasis)+ & #evidence .~ Just fixtureEvidence+ zero = responseWith (knownUsage 0 C.providerReportedBasis & #inputTokens .~ 0 & #totalTokens .~ 0)+ missing =+ responseWith+ ( U.zeroUsage+ & #availability+ .~ Just+ ( U.UsageAvailability+ (Set.fromList [U.InputUsage, U.OutputUsage, U.CacheReadUsage, U.CacheWriteUsage])+ False+ Set.empty+ )+ & #cost .~ C.estimateCost [C.UsageNotReported] C.zeroCost+ )+ (model, registry, requests) <- scriptedTransport [failure, success, zero, missing]+ (observer, snapshot) <- B.newBillingCollectorWithLimit 10+ cache <- newMemoryCache+ let cfg = (L.defaultLLMConfig registry) {L.observer = Just observer, L.retryPolicy = L.RetryPolicy 2 0 0}+ ds = dataset [Example (Question q) (Answer "It is forty-two." 0.9) | q <- ["retry", "zero", "missing", "retry"]]+ (result, tree) <-+ runEff+ . runPrim+ . runTime+ . runConcurrent+ . Tr.runTrace+ . runErrorNoCallStack @ShikumiError+ . runRouting model+ . runCacheMemory cache+ . L.runLLMResilient cfg+ . cachedLLM+ . Tr.tracedLLM+ . withRequestDefaults (emptyRequestDefaults {defaultMaxTokens = Just 123})+ . routeLLM+ $ Tr.withSpan Tr.ProgramSpan "billing evaluation"+ $ evaluateWith (R.defaultEvalConfig {R.concurrency = 1}) ds (\_ _ -> pure scoreOne) instructedProg+ report <- either (\e -> assertFailure (show e) >> fail "unreachable") pure result+ summary <- snapshot+ let attached = R.attachBillingSummary summary report+ attachedTree = Tr.attachBillingSummary summary tree+ R.totalCostUsd (R.usage attached) @?= 4 % 100+ C.usd (U.cost (B.getUsage (B.observedUsage summary))) @?= 3 % 100+ B.completedAttempts summary @?= 3+ B.failedAttempts summary @?= 1+ B.unknownUsageAttempts summary @?= 1+ map B.attempt (B.retainedAttempts summary) @?= [1, 2, 1, 1]+ assertBool "estimate survives aggregation" (Set.member C.PricingUnavailable (C.estimateReasons (C.basis (U.cost (B.getUsage (B.observedUsage summary))))))+ captured <- requests+ length captured @?= 4+ assertBool "observer sees effective defaults" (all (\(_, o) -> o ^. #maxTokens == Just 123) captured)+ assertBool "both report views rendered" (all (`T.isInfixOf` R.renderReportText attached) ["logical usage quality", "transport billing", "usage_not_reported", "provider_reported_total"])+ eitherDecode (encode attachedTree) @?= Right attachedTree+ replayIndex attachedTree @?= replayIndex tree+ case replayIndex attachedTree of+ Right index -> Map.size index @?= 3+ Left err -> assertFailure (T.unpack err)+ assertBool "failure text never retained" (not ("private provider output" `T.isInfixOf` B.renderBillingSummary summary))+ (processor, ref) <- inMemoryListExporter+ exportTreeWith processor "billing-test" attachedTree+ emitted <- readIORef ref+ attrs <- mapM (fmap (Attr.getAttributeMap . Otel.hotAttributes) . readIORef . Otel.spanHot) emitted+ let scope name = filter ((== Just (Attr.toAttribute (name :: T.Text))) . HM.lookup "shikumi.accounting.scope") attrs+ length (scope "logical-call") @?= 4+ length (filter (HM.member "gen_ai.response.model") (scope "logical-call")) @?= 1+ length (scope "transport-attempt") @?= 4+ length (scope "transport-summary") @?= 1+ assertBool "requested model is never claimed as observed" (all ((/= Just (Attr.toAttribute ("requested-model" :: T.Text))) . HM.lookup "gen_ai.response.model") attrs)+ assertBool "actual observed identity exported" (any ((== Just (Attr.toAttribute ("observed-model" :: T.Text))) . HM.lookup "gen_ai.response.model") attrs)+ assertBool "billing basis exported" (any (HM.member "shikumi.cost.basis") (scope "transport-attempt"))+ statuses <- mapM (fmap Otel.hotStatus . readIORef . Otel.spanHot) emitted+ assertBool "failed attempt is an error span" (Otel.Error "TransientError" `elem` statuses),+ testCase "FailScore and FailAbort retain terminal failure billing" $ do+ let failure =+ responseWith (knownUsage (1 % 100) C.providerReportedBasis)+ & #message . #stopReason .~ ErrorReason+ & #errorInfo .~ Just ((BE.providerError "refused") {BE.category = BE.ContentFiltered})+ ds = dataset [Example (Question "refuse") (Answer "unused" 1)]+ run policy = do+ (model, registry, _) <- scriptedTransport [failure]+ (observer, snapshot) <- B.newBillingCollector+ result <-+ runEff+ . runPrim+ . runTime+ . runConcurrent+ . runErrorNoCallStack @ShikumiError+ . runRouting model+ . L.runLLMResilient ((L.defaultLLMConfig registry) {L.observer = Just observer})+ . routeLLM+ $ evaluateWith (R.defaultEvalConfig {R.failurePolicy = policy}) ds (\_ _ -> pure scoreOne) instructedProg+ summary <- snapshot+ B.failedAttempts summary @?= 1+ C.usd (U.cost (B.getUsage (B.observedUsage summary))) @?= 1 % 100+ pure result+ scored <- run (R.failurePolicy R.defaultEvalConfig)+ case scored of+ Right r -> do R.failCount r @?= 1; R.totalCostUsd (R.usage r) @?= 0+ Left e -> assertFailure (show e)+ aborted <- run R.FailAbort+ assertBool "FailAbort propagates" (either (const True) (const False) aborted),+ testCase "bare completion and stream agree; separate concurrent collectors do not mix" $ do+ let action amount = do+ (model, registry, _) <- scriptedTransport [responseWith (knownUsage amount C.standardCostBasis)]+ (observer, snapshot) <- B.newBillingCollectorWithLimit 2+ _ <-+ runEff . runErrorNoCallStack @ShikumiError . L.runLLMWithObserver registry observer $+ L.complete model emptyContext emptyOptions+ snapshot+ summaries <- runEff . runConcurrent $ mapConcurrently (liftIO . action) [1 % 3, 2 % 3]+ map (C.usd . U.cost . B.getUsage . B.observedUsage) summaries @?= [1 % 3, 2 % 3]+ a <- action (1 % 3)+ (model, registry, _) <- scriptedTransport [responseWith (knownUsage (1 % 3) C.standardCostBasis) & #evidence .~ Just fixtureEvidence]+ (observer, snapshot) <- B.newBillingCollectorWithLimit 2+ (_, streamTree) <-+ runEff+ . runPrim+ . runTime+ . Tr.runTrace+ . runErrorNoCallStack @ShikumiError+ . L.runLLMWithObserver registry observer+ . Tr.tracedLLM+ $ L.stream model emptyContext emptyOptions+ case Map.elems (Tr.spans streamTree) of+ [span] -> do+ Tr.billingQuality (Tr.attrs span) @?= Just (B.UsageRecord (knownUsage (1 % 3) C.standardCostBasis))+ Tr.observedModel (Tr.attrs span) @?= Just "observed-model"+ Tr.response (Tr.attrs span) @?= Nothing+ _ -> assertFailure "expected one streaming span"+ replayIndex streamTree @?= Right Map.empty+ b <- snapshot+ B.observedUsage a @?= B.observedUsage b+ B.completedAttempts a @?= 1+ B.completedAttempts b @?= 1+ ]
test/Main.hs view
@@ -6,6 +6,7 @@ -- non-root span's parent is another emitted span). module Main (main) where +import BillingSpec qualified import Control.Exception (SomeException, try) import Data.Aeson (Value (..), object, (.=)) import Data.HashMap.Strict qualified as HashMap@@ -42,7 +43,8 @@ defaultMain $ testGroup "shikumi-trace-otel"- [ nestingTest,+ [ BillingSpec.tests,+ nestingTest, liveExportInMemoryTest, exceptionReleasesProviderTest, responseStatusAndModelTest,@@ -132,10 +134,10 @@ other -> assertFailure ("expected Error status, got " <> show other) spanStatus okSpan >>= assertEqual "successful response status" Otel.Ok errHasResponseModel <- spanHasAttr "gen_ai.response.model" errSpan- assertBool "error response without echoed model omits gen_ai.response.model" (not errHasResponseModel)+ assertBool "error response without observed model omits gen_ai.response.model" (not errHasResponseModel) spanAttr "gen_ai.response.model" okSpan >>= assertEqual- "response model comes from echoed response model"+ "response model comes from observed evidence" (Just (Attr.toAttribute ("claude-sonnet-4-6-20250929" :: Text))) openSpanTest :: TestTree@@ -217,7 +219,8 @@ fixedTree :: TraceTree fixedTree = TraceTree- { root = SpanId "s0",+ { transportBilling = Nothing,+ root = SpanId "s0", spans = Map.fromList [ (SpanId "s0", node "s0" Nothing ProgramSpan "summarize-and-critique" emptyAttrs 0),@@ -231,7 +234,8 @@ throwingTree :: TraceTree throwingTree = TraceTree- { root = SpanId "s0",+ { transportBilling = Nothing,+ root = SpanId "s0", spans = Map.fromList [ (SpanId "s0", node "s0" Nothing ProgramSpan (error "trace label exploded") emptyAttrs 0)@@ -241,7 +245,8 @@ responseTree :: TraceTree responseTree = TraceTree- { root = SpanId "s0",+ { transportBilling = Nothing,+ root = SpanId "s0", spans = Map.fromList [ (SpanId "s0", node "s0" Nothing ProgramSpan "responses" emptyAttrs 0),@@ -263,7 +268,8 @@ rootCyclicTree :: TraceTree rootCyclicTree = TraceTree- { root = SpanId "s0",+ { transportBilling = Nothing,+ root = SpanId "s0", spans = Map.fromList [ (SpanId "s0", node "s0" (Just "s0") ProgramSpan "self-parented" emptyAttrs 0)@@ -309,7 +315,8 @@ okResponseAttrs :: SpanAttrs okResponseAttrs = llmAttrs- { response =+ { observedModel = Just "claude-sonnet-4-6-20250929",+ response = Just ( object [ "errorInfo" .= Null,