shikumi-trace-otel (empty) → 0.1.0.0
raw patch · 5 files changed
+415/−0 lines, 5 filesdep +basedep +containersdep +generic-lens
Dependencies added: base, containers, generic-lens, hs-opentelemetry-api, hs-opentelemetry-exporter-in-memory, hs-opentelemetry-exporter-otlp, hs-opentelemetry-sdk, hs-opentelemetry-semantic-conventions, lens, scientific, shikumi-trace, shikumi-trace-otel, tasty, tasty-hunit, text, time, unordered-containers
Files
- CHANGELOG.md +10/−0
- shikumi-trace-otel.cabal +78/−0
- src/Shikumi/Trace/LiveExport.hs +57/−0
- src/Shikumi/Trace/OpenTelemetry.hs +117/−0
- test/Main.hs +153/−0
+ CHANGELOG.md view
@@ -0,0 +1,10 @@+# Changelog++## Unreleased++## 0.1.0.0 - 2026-06-13++### Added++- Initial Hackage release of OpenTelemetry export for shikumi trace trees.+- Batch and live export helpers that map shikumi spans to OpenTelemetry spans with GenAI-oriented attributes.
+ shikumi-trace-otel.cabal view
@@ -0,0 +1,78 @@+cabal-version: 3.4+name: shikumi-trace-otel+version: 0.1.0.0+synopsis:+ OpenTelemetry export of shikumi hierarchical trace trees (EP-7)++category: AI+description:+ An opt-in OpenTelemetry adapter for shikumi's hierarchical trace trees.+ 'Shikumi.Trace.OpenTelemetry.exportTree' walks a finished 'TraceTree' and emits+ one OTel span per node, preserving parent/child nesting (each child span is+ created in a context carrying its parent span). LM-call spans carry GenAI+ semantic-convention attributes; every span carries @shikumi.@-prefixed+ span-kind/cost/latency/retry attributes. The heavy @hs-opentelemetry-*@+ dependency tree is isolated in this package, mirroring baikai's+ @baikai-trace-otel@ split.++license: BSD-3-Clause+author: Nadeem Bitar+maintainer: nadeem@gmail.com+build-type: Simple+extra-doc-files: CHANGELOG.md++common common-options+ ghc-options:+ -Wall -Wcompat -Widentities -Wincomplete-uni-patterns+ -Wincomplete-record-updates -Wredundant-constraints+ -fhide-source-paths -Wmissing-export-lists -Wpartial-fields+ -Wmissing-deriving-strategies++ default-language: GHC2024+ default-extensions:+ DeriveAnyClass+ DuplicateRecordFields+ OverloadedLabels+ OverloadedStrings++library+ import: common-options+ hs-source-dirs: src+ exposed-modules:+ Shikumi.Trace.LiveExport+ Shikumi.Trace.OpenTelemetry++ build-depends:+ , base >=4.20 && <5+ , containers+ , generic-lens+ , hs-opentelemetry-api >=1.0 && <1.1+ , hs-opentelemetry-exporter-otlp >=1.0 && <1.1+ , hs-opentelemetry-sdk >=1.0 && <1.1+ , hs-opentelemetry-semantic-conventions >=1.40 && <2+ , lens ^>=5.3+ , scientific+ , shikumi-trace ^>=0.1.0.0+ , text ^>=2.1+ , time+ , unordered-containers++test-suite shikumi-trace-otel-test+ import: common-options+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ ghc-options: -threaded -with-rtsopts=-N+ build-depends:+ , base+ , containers+ , 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.1.0.0+ , shikumi-trace-otel ^>=0.1.0.0+ , tasty+ , tasty-hunit+ , text+ , time+ , unordered-containers
+ src/Shikumi/Trace/LiveExport.hs view
@@ -0,0 +1,57 @@+-- | Live OpenTelemetry export of a shikumi 'TraceTree' (EP-17).+--+-- 'Shikumi.Trace.OpenTelemetry.exportTree' turns a finished tree into nested OTel+-- spans against a 'Otel.Tracer', but a bare tracer exports nothing on its own — it+-- needs a 'TracerProvider' holding a span processor that wraps an exporter. This+-- module supplies that orchestration:+--+-- * 'exportTreeWith' builds a provider from a given 'SpanProcessor', makes a+-- tracer, runs 'exportTree', then flushes and shuts the provider down. Factored+-- on the processor so the hermetic test passes the in-memory recording+-- processor and the live path passes the OTLP batch processor — one code path.+-- * 'exportTreeLive' builds the real OTLP/HTTP exporter from the standard+-- @OTEL_*@ environment variables (default endpoint @http:\/\/localhost:4318@),+-- wraps it in a batch processor, and exports through 'exportTreeWith'.+--+-- The batch processor requires the @-threaded@ runtime; both this package's test+-- suite and the @shikumi@ executable already pass it. Because the batch processor+-- exports on a timer, 'exportTreeWith' shuts the provider down before returning so+-- the spans are flushed.+module Shikumi.Trace.LiveExport+ ( exportTreeWith,+ exportTreeLive,+ )+where++import Control.Monad.IO.Class (MonadIO)+import Data.String (fromString)+import Data.Text (Text)+import Data.Text qualified as T+import OpenTelemetry.Exporter.OTLP.Span (loadExporterEnvironmentVariables, otlpExporter)+import OpenTelemetry.Processor.Batch.Span (batchProcessor, batchTimeoutConfig)+import OpenTelemetry.Processor.Span (SpanProcessor)+import OpenTelemetry.Trace.Core qualified as Otel+import Shikumi.Trace (TraceTree)+import Shikumi.Trace.OpenTelemetry (exportTree)++-- | Build a 'TracerProvider' around the given processor, export the tree through+-- the shared 'exportTree' walker, then flush and shut the provider down. The+-- shutdown flushes the batch processor, so spans have left by the time this+-- returns.+exportTreeWith :: (MonadIO m) => SpanProcessor -> Text -> TraceTree -> m ()+exportTreeWith processor name tree = do+ tp <- Otel.createTracerProvider [processor] Otel.emptyTracerProviderOptions+ let tracer = Otel.makeTracer tp (fromString (T.unpack name)) Otel.tracerOptions+ exportTree tracer tree+ _ <- Otel.shutdownTracerProvider tp Nothing+ pure ()++-- | The live path: load OTLP config from the standard @OTEL_EXPORTER_OTLP_*@+-- environment variables (default endpoint @http:\/\/localhost:4318@), build the+-- OTLP/HTTP exporter, wrap it in a batch processor, and export the tree.+exportTreeLive :: (MonadIO m) => Text -> TraceTree -> m ()+exportTreeLive name tree = do+ cfg <- loadExporterEnvironmentVariables+ exporter <- otlpExporter cfg+ processor <- batchProcessor batchTimeoutConfig exporter+ exportTreeWith processor name tree
+ src/Shikumi/Trace/OpenTelemetry.hs view
@@ -0,0 +1,117 @@+-- | Export a shikumi 'TraceTree' as nested OpenTelemetry spans (EP-7, M4).+--+-- Unlike baikai's @baikai-trace-otel@ (one flat span per provider call),+-- 'exportTree' preserves the tree's parent/child nesting: it walks from the root+-- depth-first and creates each child span in a 'Context' carrying its parent span+-- (via 'OpenTelemetry.Context.insertSpan'), the explicit-context approach for+-- reconstructing a recorded tree rather than discovering it from the live call+-- stack. Each span's start/end times come from the recorded 'startedAt'/'endedAt'.+--+-- LM-call spans carry GenAI semantic-convention attributes+-- (@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@); every span carries @shikumi.@-prefixed attributes+-- (@shikumi.span_kind@, @shikumi.retries@, and, on LM-call spans,+-- @shikumi.cost.usd@ / @shikumi.latency_ms@).+module Shikumi.Trace.OpenTelemetry+ ( exportTree,+ )+where++import Control.Lens ((^.))+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Generics.Labels ()+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.Int (Int64)+import Data.Map.Strict qualified as Map+import Data.Scientific qualified as Sci+import Data.Text (Text)+import Data.Time (UTCTime)+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)+import Data.Word (Word64)+import OpenTelemetry.Attributes qualified as Attr+import OpenTelemetry.Attributes.Map qualified as AttrMap+import OpenTelemetry.Common (Timestamp, mkTimestamp)+import OpenTelemetry.Context qualified as Context+import OpenTelemetry.SemanticConventions qualified as SC+import OpenTelemetry.Trace.Core qualified as Otel+import Shikumi.Trace (Span, SpanAttrs, SpanKind (..), TraceTree, childrenOf)+import Shikumi.Trace.Node (renderNodePath)++-- | Export a finished trace tree to a tracer as a forest of nested spans. Pure+-- 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 Context.empty (tree ^. #root))+ where+ smap = tree ^. #spans+ go ctx sid = case Map.lookup sid smap of+ Nothing -> pure ()+ Just s -> do+ sp <- Otel.createSpan tracer ctx (s ^. #label) (argsFor s)+ Otel.addAttributes sp (attrsFor s)+ Otel.setStatus sp Otel.Ok+ let childCtx = Context.insertSpan sp Context.empty+ mapM_ (go childCtx) (childrenOf tree sid)+ Otel.endSpan sp (utcToTimestamp <$> (s ^. #endedAt))++ argsFor :: Span -> Otel.SpanArguments+ argsFor s =+ Otel.defaultSpanArguments+ { Otel.kind = if (s ^. #kind) == LlmCallSpan then Otel.Client else Otel.Internal,+ Otel.startTime = Just (utcToTimestamp (s ^. #startedAt))+ }++-- | The OTel attribute map for a span. Every span carries @shikumi.span_kind@ and+-- @shikumi.retries@, plus — when the source span was produced by+-- @runProgramTraced@ (EP-16) — @shikumi.node_path@, the structural path of the+-- @Program@ node that issued it. LM-call spans additionally carry the GenAI+-- attributes.+attrsFor :: Span -> HashMap Text Attr.Attribute+attrsFor s =+ let a = s ^. #attrs+ base =+ HashMap.fromList+ [ ("shikumi.span_kind", Attr.toAttribute (kindText (s ^. #kind))),+ ("shikumi.retries", Attr.toAttribute (a ^. #retries))+ ]+ withNode =+ maybe+ id+ (\p -> HashMap.insert "shikumi.node_path" (Attr.toAttribute (renderNodePath p)))+ (a ^. #nodePath)+ in withNode (if (s ^. #kind) == LlmCallSpan then genAiAttrs a base else base)++-- | Overlay the GenAI semantic-convention attributes (plus shikumi cost/latency)+-- for an LM-call span.+genAiAttrs :: SpanAttrs -> HashMap Text Attr.Attribute -> HashMap Text Attr.Attribute+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) (a ^. #model) $+ 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++kindText :: SpanKind -> Text+kindText = \case+ ProgramSpan -> "program"+ ModuleSpan -> "module"+ CombinatorSpan -> "combinator"+ LlmCallSpan -> "llm-call"++-- | Convert a 'UTCTime' to an OpenTelemetry 'Timestamp' (nanoseconds since the+-- Unix epoch), going via 'Rational' so no precision is dropped (mirrors baikai's+-- adapter).+utcToTimestamp :: UTCTime -> Timestamp+utcToTimestamp t =+ let posix :: Rational+ posix = toRational (utcTimeToPOSIXSeconds t)+ secs :: Word64+ secs = floor posix+ nanos :: Word64+ nanos = floor ((posix - toRational secs) * 1_000_000_000)+ in mkTimestamp secs nanos
+ test/Main.hs view
@@ -0,0 +1,153 @@+{-# LANGUAGE ScopedTypeVariables #-}++-- | M4 acceptance for shikumi-trace-otel: exporting a fixed two-stage trace tree+-- emits the right number of OTel spans, the LM-call spans carry GenAI attributes,+-- and the parent/child nesting survives (all spans share one trace id; each+-- non-root span's parent is another emitted span).+module Main (main) where++import Data.HashMap.Strict qualified as HashMap+import Data.IORef (IORef, readIORef)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes)+import Data.Text (Text)+import Data.Time (UTCTime)+import OpenTelemetry.Attributes qualified as Attr+import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)+import OpenTelemetry.Trace.Core qualified as Otel+import OpenTelemetry.Trace.Id qualified as OtelId+import Shikumi.Trace+ ( Span (..),+ SpanAttrs (..),+ SpanId (..),+ SpanKind (..),+ TraceTree (..),+ emptyAttrs,+ )+import Shikumi.Trace.LiveExport (exportTreeWith)+import Shikumi.Trace.Node (NodePath (..), NodeStep (..))+import Shikumi.Trace.OpenTelemetry (exportTree)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++main :: IO ()+main = defaultMain $ testGroup "shikumi-trace-otel" [nestingTest, liveExportInMemoryTest]++nestingTest :: TestTree+nestingTest =+ testCase "exportTree emits nested spans with GenAI attributes" $ do+ (tracer, getSpans) <- newTracerWithInMemory+ exportTree tracer fixedTree+ emitted <- getSpans+ -- (a) one span per tree node.+ assertEqual "one OTel span per tree node" (Map.size (spans fixedTree)) (length emitted)+ -- (b) the two LM-call spans carry gen_ai.request.model.+ let llmSpans = [s | s <- emitted, Otel.spanKind s == Otel.Client]+ assertEqual "two LM-call (Client) spans" 2 (length llmSpans)+ modelled <-+ mapM+ ( \s -> do+ hot <- readIORef (Otel.spanHot s)+ pure (HashMap.member "gen_ai.request.model" (Attr.getAttributeMap (Otel.hotAttributes hot)))+ )+ llmSpans+ assertBool "every LM-call span has gen_ai.request.model" (and modelled)+ -- (c) nesting survived: all spans share one trace id; every non-root span's+ -- parent is one of the emitted spans.+ let traceIds = map (Otel.traceId . Otel.spanContext) emitted+ case traceIds of+ (t0 : rest) -> assertBool "all spans share one trace id" (all (== t0) rest)+ [] -> assertBool "no spans" False+ let emittedSpanIds = map (Otel.spanId . Otel.spanContext) emitted+ parentIds <- mapM parentSpanId emitted+ let presentParents = catMaybes parentIds+ assertEqual "exactly one root (no parent)" 1 (length emitted - length presentParents)+ assertBool+ "every parent is an emitted span"+ (all (`elem` emittedSpanIds) presentParents)++-- | EP-17 M1+M3: the full live orchestration (provider + batch processor + export+-- + shutdown-flush) driven through 'exportTreeWith' with the in-memory recording+-- processor swapped in for the OTLP one — the exact code path the CLI calls, minus+-- the network. Asserts one span per node, the GenAI attributes, and (EP-16 landed)+-- the @shikumi.node_path@ attribute on the model-call spans.+liveExportInMemoryTest :: TestTree+liveExportInMemoryTest =+ testCase "exportTreeWith (recording processor) preserves nesting and node attrs" $ do+ (proc, spansRef :: IORef [Otel.ImmutableSpan]) <- inMemoryListExporter+ exportTreeWith proc "shikumi-live-test" fixedTree+ emitted <- reverse <$> readIORef spansRef+ assertEqual "one OTel span per tree node" (Map.size (spans fixedTree)) (length emitted)+ let llmSpans = [s | s <- emitted, Otel.spanKind s == Otel.Client]+ assertEqual "two LM-call (Client) spans" 2 (length llmSpans)+ modelled <- mapM (spanHasAttr "gen_ai.request.model") llmSpans+ assertBool "every LM-call span has gen_ai.request.model" (and modelled)+ tagged <- mapM (spanHasAttr "shikumi.node_path") llmSpans+ assertBool "every LM-call span carries shikumi.node_path (EP-16 landed)" (and tagged)++-- | Whether the recorded span's attribute map contains a key.+spanHasAttr :: Text -> Otel.ImmutableSpan -> IO Bool+spanHasAttr key s = do+ hot <- readIORef (Otel.spanHot s)+ pure (HashMap.member key (Attr.getAttributeMap (Otel.hotAttributes hot)))++-- | The recorded parent span id of an immutable span, if it has a parent.+parentSpanId :: Otel.ImmutableSpan -> IO (Maybe OtelId.SpanId)+parentSpanId s = case Otel.spanParent s of+ Nothing -> pure Nothing+ Just p -> Just . Otel.spanId <$> Otel.getSpanContext p++-- | A tracer wired to an in-memory span exporter, plus a reader for the captured+-- spans (in emission order).+newTracerWithInMemory :: IO (Otel.Tracer, IO [Otel.ImmutableSpan])+newTracerWithInMemory = do+ (proc, spansRef :: IORef [Otel.ImmutableSpan]) <- inMemoryListExporter+ tp <- Otel.createTracerProvider [proc] Otel.emptyTracerProviderOptions+ let tracer = Otel.makeTracer tp "shikumi-trace-otel-test" Otel.tracerOptions+ pure (tracer, reverse <$> readIORef spansRef)++-- ---------------------------------------------------------------------------+-- A fixed two-stage tree: program -> {draft module -> draft llm, critique module+-- -> critique llm}. Five spans, two of them LM-call leaves.+-- ---------------------------------------------------------------------------++fixedTree :: TraceTree+fixedTree =+ TraceTree+ { root = SpanId "s0",+ spans =+ Map.fromList+ [ (SpanId "s0", node "s0" Nothing ProgramSpan "summarize-and-critique" emptyAttrs 0),+ (SpanId "s1", node "s1" (Just "s0") ModuleSpan "predict:Draft" emptyAttrs 1),+ (SpanId "s2", node "s2" (Just "s1") LlmCallSpan "anthropic/claude" llmAttrs 2),+ (SpanId "s3", node "s3" (Just "s0") ModuleSpan "predict:Critique" emptyAttrs 3),+ (SpanId "s4", node "s4" (Just "s3") LlmCallSpan "anthropic/claude" llmAttrs 4)+ ]+ }++node :: Text -> Maybe Text -> SpanKind -> Text -> SpanAttrs -> Int -> Span+node sid par k lbl a n =+ Span+ { spanId = SpanId sid,+ parent = SpanId <$> par,+ kind = k,+ label = lbl,+ startedAt = at (2 * n),+ endedAt = Just (at (2 * n + 1)),+ attrs = a+ }++llmAttrs :: SpanAttrs+llmAttrs =+ emptyAttrs+ { model = Just "claude-sonnet-4-6",+ provider = Just "anthropic",+ inputTokens = Just 812,+ outputTokens = Just 143,+ latencyMs = Just 205,+ -- EP-16 landed: model-call spans carry their issuing node's path.+ nodePath = Just (NodePath [StepComposeL])+ }++at :: Int -> UTCTime+at n = read ("2026-06-08 00:00:0" <> show n <> " UTC")