;; lib/host/tests/otel.sx — P1: span model + API. A span dict ;; {:trace :span :parent :name :t0 :t1 :attrs :events}; otel/with-span records ;; t0/t1 and pushes/pops a dynamic parent stack so nesting builds the tree; a ;; bounded ring buffer (record!/recent, cap, drop-oldest); current-span/current-trace. (define host-ot-pass 0) (define host-ot-fail 0) (define host-ot-fails (list)) (define host-ot-test (fn (name actual expected) (if (= actual expected) (set! host-ot-pass (+ host-ot-pass 1)) (begin (set! host-ot-fail (+ host-ot-fail 1)) (append! host-ot-fails {:name name :actual actual :expected expected}))))) ;; ── nested with-span builds the parent tree ───────────────────────── (otel/reset!) (otel/with-span "root" {} (fn () (otel/with-span "child" {} (fn () 42)))) (define host-ot-sp (otel/recent)) (define host-ot-child (first host-ot-sp)) ;; inner span completes+records first (define host-ot-root (nth host-ot-sp 1)) (host-ot-test "two spans recorded" (len host-ot-sp) 2) (host-ot-test "child name" (get host-ot-child :name) "child") (host-ot-test "root name" (get host-ot-root :name) "root") (host-ot-test "child parent is root span" (get host-ot-child :parent) (get host-ot-root :span)) (host-ot-test "root has no parent" (get host-ot-root :parent) nil) (host-ot-test "same trace" (= (get host-ot-child :trace) (get host-ot-root :trace)) true) (host-ot-test "root t1 >= t0" (>= (get host-ot-root :t1) (get host-ot-root :t0)) true) (host-ot-test "root has attrs" (get host-ot-root :attrs) {}) (host-ot-test "root has events list" (get host-ot-root :events) (list)) ;; ── attrs are carried through ─────────────────────────────────────── (otel/reset!) (otel/with-span "req" {:http.method "GET" :http.route "/feed"} (fn () nil)) (host-ot-test "attrs carried" (get (get (first (otel/recent)) :attrs) :http.method) "GET") ;; ── current-span / current-trace track the dynamic stack ──────────── (otel/reset!) (host-ot-test "no current span outside" (otel/current-span) nil) (host-ot-test "no current trace outside" (otel/current-trace) nil) (otel/with-span "x" {} (fn () (begin (host-ot-test "current span set inside" (not (= (otel/current-span) nil)) true) (host-ot-test "current trace set inside" (not (= (otel/current-trace) nil)) true) nil))) (host-ot-test "no current span after" (otel/current-span) nil) ;; ── ring buffer caps at N, drops oldest ───────────────────────────── (otel/reset!) (otel/set-cap! 3) (for-each (fn (i) (otel/record! {:span i :name "s"})) (list 1 2 3 4 5)) (host-ot-test "ring capped at 3" (len (otel/recent)) 3) (host-ot-test "oldest dropped" (get (first (otel/recent)) :span) 3) (host-ot-test "newest kept" (get (last (otel/recent)) :span) 5) (otel/set-cap! 1000) ;; ── P2: now-ns wraps the host monotonic clock ────────────────────── ;; now-ns is real epoch NANOSECONDS (clock-milliseconds * 1e6), clamped so it ;; never goes backwards. Non-negative, non-decreasing, nanosecond-scale. (define host-ot-n0 (otel/now-ns)) (define host-ot-n1 (otel/now-ns)) (host-ot-test "now-ns non-negative" (>= host-ot-n0 0) true) (host-ot-test "now-ns monotonic non-decreasing" (>= host-ot-n1 host-ot-n0) true) (host-ot-test "now-ns is nanosecond-scale" (> host-ot-n0 1000000000000000) true) ;; a real with-span straddles the host clock (otel/reset!) (otel/with-span "timed" {} (fn () nil)) (define host-ot-timed (first (otel/recent))) (host-ot-test "timed span t1 >= t0" (>= (get host-ot-timed :t1) (get host-ot-timed :t0)) true) (host-ot-test "timed span t0 nanosecond-scale" (> (get host-ot-timed :t0) 1000000000000000) true) ;; ── P3: auto-instrument handlers — a request becomes a trace ──────── ;; otel/instrument-routes wraps each route's handler so dispatching a request ;; records a root span named "METHOD /route" with http.method/route/status attrs. (otel/reset!) (define host-ot-routes (list (dream-get "/feed" (fn (req) (dream-response 200 {} "ok"))) (dream-post "/feed" (fn (req) (dream-response 201 {} "made"))))) (define host-ot-iapp (dream-router (otel/instrument-routes host-ot-routes))) (host-ot-iapp (dream-request "GET" "/feed" {} "")) (host-ot-test "one span for one request" (len (otel/recent)) 1) (define host-ot-is (first (otel/recent))) (host-ot-test "span name is method+route" (get host-ot-is :name) "GET /feed") (host-ot-test "http.method attr" (get (get host-ot-is :attrs) :http.method) "GET") (host-ot-test "http.route attr" (get (get host-ot-is :attrs) :http.route) "/feed") (host-ot-test "http.status attr" (get (get host-ot-is :attrs) :http.status) 200) (host-ot-test "request span is a root (no parent)" (get host-ot-is :parent) nil) (host-ot-test "request span has a trace id" (not (= (get host-ot-is :trace) nil)) true) ;; a second request → its own span + trace, status from its response (host-ot-iapp (dream-request "POST" "/feed" {} "x")) (host-ot-test "two requests two spans" (len (otel/recent)) 2) (define host-ot-is2 (last (otel/recent))) (host-ot-test "post span name" (get host-ot-is2 :name) "POST /feed") (host-ot-test "post status attr" (get (get host-ot-is2 :attrs) :http.status) 201) (host-ot-test "distinct trace per request" (not (= (get host-ot-is :trace) (get host-ot-is2 :trace))) true) ;; bare-string handler results still get a status (coerced to 200) (otel/reset!) (define host-ot-sapp (dream-router (otel/instrument-routes (list (dream-get "/plain" (fn (req) "hello")))))) (host-ot-sapp (dream-request "GET" "/plain" {} "")) (host-ot-test "string handler status coerced to 200" (get (get (first (otel/recent)) :attrs) :http.status) 200) ;; ── P3 integration: make-app traces every request ────────────────── (otel/reset!) (feed/reset!) (define host-ot-happ (host/make-app (list host/feed-routes))) (host-ot-happ (dream-request "GET" "/health" {} "")) (define host-ot-hs (first (filter (fn (s) (= (get s :name) "GET /health")) (otel/recent)))) (host-ot-test "make-app traces the health request" (not (= host-ot-hs nil)) true) (host-ot-test "make-app health status 200" (get (get host-ot-hs :attrs) :http.status) 200) ;; ── P4: render-fold → SVG waterfall ──────────────────────────────── ;; A trace (tree of spans) folds into an : one per span, x by start ;; offset, width by duration, y by depth. Build a root with nested children. (otel/reset!) (otel/with-span "root" {} (fn () (begin (otel/with-span "a" {} (fn () nil)) (otel/with-span "b" {} (fn () (otel/with-span "b1" {} (fn () nil)))) nil))) (define host-ot-tid (get (first (otel/recent)) :trace)) (define host-ot-rects (otel/waterfall-rects host-ot-tid)) (define host-ot-rect-named (fn (nm) (first (filter (fn (r) (= (get r :name) nm)) host-ot-rects)))) (host-ot-test "one rect per span" (len host-ot-rects) 4) (host-ot-test "root depth 0" (get (host-ot-rect-named "root") :depth) 0) (host-ot-test "child a depth 1" (get (host-ot-rect-named "a") :depth) 1) (host-ot-test "grandchild b1 depth 2" (get (host-ot-rect-named "b1") :depth) 2) (host-ot-test "nested span y greater than root y" (> (get (host-ot-rect-named "a") :y) (get (host-ot-rect-named "root") :y)) true) (host-ot-test "deeper nesting greater y" (> (get (host-ot-rect-named "b1") :y) (get (host-ot-rect-named "b") :y)) true) (host-ot-test "root x is the left pad (starts at trace t0)" (get (host-ot-rect-named "root") :x) 4) (host-ot-test "all widths positive" (every? (fn (r) (> (get r :w) 0)) host-ot-rects) true) ;; the SVG itself: an svg head with one rect + one label per span (define host-ot-svg (otel/waterfall host-ot-tid)) (host-ot-test "waterfall head is svg" (str (first host-ot-svg)) "svg") (host-ot-test "svg has one rect per span" (otel/-tree-count host-ot-svg "rect") 4) (host-ot-test "svg has at least one label per span" (>= (otel/-tree-count host-ot-svg "text") 4) true) (host-ot-test "waterfall has a time ruler (gridlines)" (> (otel/-tree-count host-ot-svg "line") 0) true) ;; unknown trace → empty waterfall, still a valid svg (host-ot-test "unknown trace has no rects" (len (otel/waterfall-rects "no-such-trace")) 0) (host-ot-test "unknown trace still yields an svg" (str (first (otel/waterfall "no-such-trace"))) "svg") ;; ── P5: metrics (aggregate-fold) ─────────────────────────────────── ;; Fold recent spans → per-route counters + latency percentiles (nearest-rank). ;; Build spans with KNOWN durations so the percentiles are deterministic. (otel/reset!) (for-each (fn (d) (otel/record! {:trace "t" :span (str "s" d) :parent nil :name "GET /feed" :t0 0 :t1 d :attrs {:http.route "/feed"} :events (list)})) (list 30 10 50 20 40)) ;; unsorted on purpose — the fold must sort (otel/record! {:trace "t" :span "h" :parent nil :name "GET /health" :t0 0 :t1 5 :attrs {:http.route "/health"} :events (list)}) (define host-ot-m (otel/metrics (otel/recent))) (host-ot-test "total requests" (get host-ot-m :total-requests) 6) (host-ot-test "two routes" (len (get host-ot-m :routes)) 2) (define host-ot-feed (first (filter (fn (r) (= (get r :route) "/feed")) (get host-ot-m :routes)))) (host-ot-test "feed count" (get host-ot-feed :count) 5) (host-ot-test "feed p50" (get host-ot-feed :p50) 30) ;; ceil(.5*5)=3 → sorted[2]=30 (host-ot-test "feed p95" (get host-ot-feed :p95) 50) ;; ceil(.95*5)=5 → sorted[4]=50 (host-ot-test "feed p99" (get host-ot-feed :p99) 50) (define host-ot-health (first (filter (fn (r) (= (get r :route) "/health")) (get host-ot-m :routes)))) (host-ot-test "health count" (get host-ot-health :count) 1) (host-ot-test "health p50 of a single sample" (get host-ot-health :p50) 5) ;; the sort helper on its own (host-ot-test "sort-nums ascending" (otel/-sort-nums (list 3 1 2 5 4)) (list 1 2 3 4 5)) ;; empty ring → zeroed metrics, no routes (otel/reset!) (define host-ot-me (otel/metrics (otel/recent))) (host-ot-test "empty metrics total 0" (get host-ot-me :total-requests) 0) (host-ot-test "empty metrics no routes" (len (get host-ot-me :routes)) 0) ;; ── P6: live dashboard (SSR + SSE) ───────────────────────────────── ;; Drive two traces through instrumented routes, then check: recent-traces lists ;; them (newest first); the dashboard SSRs to HTML with the metrics strip + the ;; latest-trace ; the /otel/stream SSE endpoint emits an SXTP span event. (otel/reset!) (define host-ot-dapp (dream-router (otel/instrument-routes (list (dream-get "/feed" (fn (req) (dream-response 200 {} "ok"))) (dream-get "/health" (fn (req) (dream-response 200 {} "ok"))))))) (host-ot-dapp (dream-request "GET" "/feed" {} "")) (host-ot-dapp (dream-request "GET" "/health" {} "")) (host-ot-test "recent-traces lists both" (len (otel/recent-traces)) 2) (host-ot-test "recent-traces newest first" (get (first (otel/recent-traces)) :name) "GET /health") (host-ot-test "recent-traces carries the actual target path" (get (first (otel/recent-traces)) :target) "/health") (host-ot-test "recent-traces carries a wall duration" (>= (get (first (otel/recent-traces)) :dur) 0) true) (host-ot-test "request span records http.target" (get (get (last (otel/recent)) :attrs) :http.target) "/health") (host-ot-test "latest-trace is the /health trace" (otel/-trace-root-name (otel/latest-trace)) "GET /health") ;; dashboard SSRs to an HTML string carrying the strip + waterfall + trace list (define host-ot-dash (render-to-html (otel/dashboard) {})) (host-ot-test "dashboard is a string" (= (type-of host-ot-dash) "string") true) (host-ot-test "dashboard has the root id" (contains? host-ot-dash "otel-dashboard") true) (host-ot-test "dashboard SSRs the waterfall svg" (contains? host-ot-dash "