From c2734679292579c08f9883b87de3a92b4edbc303 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 1 Jul 2026 15:24:00 +0000 Subject: [PATCH] otel P5: metrics aggregate-fold (per-route counts + p50/p95/p99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit otel/metrics folds spans → {:total-requests :routes}; each route carries a request count and nearest-rank latency percentiles over its durations. Route key is the http.route attr (falls back to span name). Includes a small insertion sort (no sort primitive) and order-preserving distinct. --- lib/host/otel.sx | 59 ++++++++++++++++++++++++++++++++++++++++++ lib/host/tests/otel.sx | 37 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/lib/host/otel.sx b/lib/host/otel.sx index f74ce117..9529b84d 100644 --- a/lib/host/otel.sx +++ b/lib/host/otel.sx @@ -214,3 +214,62 @@ (let ((self (if (and (not (empty? tree)) (= (str (first tree)) head)) 1 0))) (reduce (fn (acc n) (+ acc (otel/-tree-count n head))) self tree)) 0))) + +;; ── P5: metrics (aggregate-fold) ────────────────────────────────────── +;; Fold the recent spans into per-route request counts + a latency histogram +;; (p50/p95/p99 from durations). No `sort` primitive here, so percentiles ride a +;; tiny insertion sort; nearest-rank keeps the maths exact for the tests. +(define otel/-insert + (fn (x sorted) + (if (empty? sorted) + (list x) + (if (<= x (first sorted)) + (cons x sorted) + (cons (first sorted) (otel/-insert x (rest sorted))))))) +(define otel/-sort-nums + (fn (lst) (reduce (fn (acc x) (otel/-insert x acc)) (list) lst))) + +;; nearest-rank percentile of an ASCENDING list: rank = ceil(p/100 · N), 1-based. +(define otel/-percentile + (fn (sorted p) + (if (empty? sorted) + 0 + (let ((n (len sorted))) + (let ((idx (- (ceil (* (/ p 100) n)) 1))) + (let ((i (if (< idx 0) 0 (if (>= idx n) (- n 1) idx)))) + (nth sorted i))))))) + +;; a span's route label: the http.route attr, else the span name. +(define otel/-span-route + (fn (s) (or (get (get s :attrs) :http.route) (get s :name)))) + +(define otel/-span-dur (fn (s) (- (get s :t1) (get s :t0)))) + +;; distinct values, order-preserving. +(define otel/-distinct + (fn (lst) + (reduce + (fn (acc x) (if (some (fn (y) (= y x)) acc) acc (append acc (list x)))) + (list) + lst))) + +;; the aggregate for one route: count + latency percentiles over its durations. +(define otel/route-metrics + (fn (spans route) + (let ((rs (filter (fn (s) (= (otel/-span-route s) route)) spans))) + (let ((durs (otel/-sort-nums (map otel/-span-dur rs)))) + {:route route + :count (len rs) + :p50 (otel/-percentile durs 50) + :p95 (otel/-percentile durs 95) + :p99 (otel/-percentile durs 99)})))) + +;; fold spans → {:total-requests N :routes (per-route metrics …)}. +(define otel/metrics + (fn (spans) + (let ((routes (otel/-distinct (map otel/-span-route spans)))) + {:total-requests (len spans) + :routes (map (fn (r) (otel/route-metrics spans r)) routes)}))) + +;; convenience: metrics over the current ring. +(define otel/metrics-recent (fn () (otel/metrics (otel/recent)))) diff --git a/lib/host/tests/otel.sx b/lib/host/tests/otel.sx index b55935c4..92e72cb3 100644 --- a/lib/host/tests/otel.sx +++ b/lib/host/tests/otel.sx @@ -165,6 +165,43 @@ (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) + (define host-ot-tests-run! (fn