diff --git a/lib/host/otel.sx b/lib/host/otel.sx
index 6fabdb2a..4bc562c0 100644
--- a/lib/host/otel.sx
+++ b/lib/host/otel.sx
@@ -474,20 +474,15 @@
(unquote (str "p99 " (otel/-ms (get r :p99))))))))))
routes)))))))))
-(define otel/dashboard
+;; the refreshing content — everything that changes as spans arrive. Served on its
+;; own at GET /otel/fragment; the poll swaps THIS into #otel-body.
+(define otel/-dashboard-body
(fn ()
(let ((m (otel/metrics (otel/recent)))
(lt (otel/latest-trace))
(traces (otel/recent-traces)))
(quasiquote
- (div :id "otel-dashboard"
- ;; SPA-native live refresh: the SX engine polls GET /otel every 3s and
- ;; swaps this div in place (outerHTML). The poll is a boosted request, so
- ;; the route returns the text/sx fragment — no full reload, stays in the
- ;; SPA. (No , which would blow away the SPA shell.)
- :sx-get "/otel" :sx-trigger "every 3s" :sx-target "#otel-dashboard" :sx-swap "outerHTML"
- (h1 "OpenTelemetry")
- (p :style "font-size:0.8em;opacity:0.7" "live · refreshes every 3s in-app")
+ (div
(h2 "latency by route")
(p :style "font-size:0.75em;opacity:0.7"
(span :style "color:#4c9a8f" "▉ p50") " "
@@ -504,6 +499,19 @@
(h2 "recent traces")
(unquote (otel/-traces-list traces)))))))
+(define otel/dashboard
+ (fn ()
+ (quasiquote
+ (div :id "otel-dashboard"
+ (h1 "OpenTelemetry")
+ (p :style "font-size:0.8em;opacity:0.7" "live · refreshes every 3s in-app")
+ ;; STABLE polling element: it fetches GET /otel/fragment every 3s and swaps
+ ;; its OWN inner content (innerHTML), so #otel-body itself is never removed —
+ ;; its poll interval keeps firing (an outerHTML self-swap would delete the
+ ;; polling element after one tick). Boosted → text/sx, so no full reload.
+ (div :id "otel-body" :sx-get "/otel/fragment" :sx-trigger "every 3s" :sx-swap "innerHTML"
+ (unquote (otel/-dashboard-body)))))))
+
;; ── routes ────────────────────────────────────────────────────────────
;; Dual-mode, wired into the SPA: a boosted (SX-Request) fetch — a link click OR
;; the 3s poll — gets the dashboard as a text/sx fragment the WASM kernel renders
@@ -515,11 +523,18 @@
(fn (req)
(host/blog--resp req 200
(host/blog--page req "OpenTelemetry" (otel/dashboard))))))
+;; the poll target: always the refreshing body as a text/sx fragment (the WASM
+;; kernel swaps it into #otel-body). Two segments, so /:slug can't shadow it.
+(define otel/dashboard-fragment-route
+ (dream-get "/otel/fragment"
+ (fn (req)
+ (dream-response 200 {:content-type "text/sx; charset=utf-8"}
+ (serialize (otel/-dashboard-body))))))
(define otel/stream-route
(dream-get "/otel/stream"
(fn (req)
(dream-response 200 {:content-type "text/event-stream"} (otel/-stream-body)))))
-(define otel/routes (list otel/dashboard-route otel/stream-route))
+(define otel/routes (list otel/dashboard-route otel/dashboard-fragment-route otel/stream-route))
;; ── P7: OTLP-JSON export ──────────────────────────────────────────────
;; Serialize spans to the OTLP/JSON schema (resourceSpans → scopeSpans → spans)
diff --git a/lib/host/tests/otel.sx b/lib/host/tests/otel.sx
index cb8fea3e..d0d9b9b7 100644
--- a/lib/host/tests/otel.sx
+++ b/lib/host/tests/otel.sx
@@ -235,7 +235,16 @@
(host-ot-test "dashboard SSRs the waterfall svg" (contains? host-ot-dash "