otel P7: OTLP-JSON export + injected transport

otel/export-otlp folds spans → OTLP/JSON envelope (resourceSpans → scopeSpans →
spans) with hex traceId(32)/spanId(16)/parentSpanId, uint64-as-string nano
timestamps, typed attributes (stringValue/intValue), and span kind
(SERVER/INTERNAL). otel/export-otlp-json encodes via dream-json-encode;
otel/post-otlp POSTs through an injected transport (testable without a live
collector).
This commit is contained in:
2026-07-01 15:54:42 +00:00
parent 4e201ad107
commit 84285d23e9
2 changed files with 142 additions and 0 deletions

View File

@@ -383,3 +383,82 @@
(fn (req)
(dream-response 200 {:content-type "text/event-stream"} (otel/-stream-body)))))
(define otel/routes (list otel/dashboard-route otel/stream-route))
;; ── P7: OTLP-JSON export ──────────────────────────────────────────────
;; Serialize spans to the OTLP/JSON schema (resourceSpans → scopeSpans → spans)
;; for interop with Jaeger/Grafana. traceId/spanId/parentSpanId are hex strings;
;; timestamps are uint64-as-string; attributes are typed {key,value}. Export is a
;; fold (span → OTLP span); POST goes through an INJECTED transport so it's
;; testable without a live collector.
;; our "trace-N"/"span-N" ids → a fixed-width lowercase-hex id (real OTLP widths:
;; traceId 16 bytes = 32 hex, spanId 8 bytes = 16 hex). Deterministic from the id.
(define otel/-id-num
(fn (id)
(let ((n (string->number (last (split id "-")))))
(if (nil? n) 0 n))))
(define otel/-zeros
(fn (k) (if (<= k 0) "" (str "0" (otel/-zeros (- k 1))))))
(define otel/-pad-hex
(fn (n width)
(let ((h (number->string n 16)))
(str (otel/-zeros (- width (len h))) h))))
(define otel/-trace-hex (fn (id) (otel/-pad-hex (otel/-id-num id) 32)))
(define otel/-span-hex (fn (id) (otel/-pad-hex (otel/-id-num id) 16)))
;; a value → OTLP AnyValue: numbers are intValue (uint64-as-string), else stringValue.
(define otel/-otlp-value
(fn (v)
(if (= (type-of v) "number")
{:intValue (str v)}
{:stringValue (str v)})))
(define otel/-otlp-attrs
(fn (attrs)
(map (fn (k) {:key (str k) :value (otel/-otlp-value (get attrs k))}) (keys attrs))))
;; span kind: 2 = SERVER for an instrumented request (has http.method), else
;; 1 = INTERNAL.
(define otel/-otlp-kind
(fn (attrs) (if (get attrs :http.method) 2 1)))
(define otel/-otlp-span
(fn (s)
(let ((attrs (or (get s :attrs) {})))
(let ((base
{:traceId (otel/-trace-hex (get s :trace))
:spanId (otel/-span-hex (get s :span))
:name (get s :name)
:kind (otel/-otlp-kind attrs)
:startTimeUnixNano (str (get s :t0))
:endTimeUnixNano (str (get s :t1))
:attributes (otel/-otlp-attrs attrs)}))
(if (nil? (get s :parent))
base
(assoc base :parentSpanId (otel/-span-hex (get s :parent))))))))
;; spans → the OTLP export envelope (a JSON-shaped SX value).
(define otel/export-otlp
(fn (spans)
{:resourceSpans
(list
{:resource
{:attributes (list {:key "service.name"
:value {:stringValue "rose-ash-host"}})}
:scopeSpans
(list
{:scope {:name "otel-on-sx" :version "0.1.0"}
:spans (map otel/-otlp-span spans)})})}))
(define otel/export-otlp-json
(fn (spans) (dream-json-encode (otel/export-otlp spans))))
;; POST the OTLP payload through an injected transport. `transport` is a fn taking
;; a request dict {:method :url :headers :body} — real deploys pass an http POST;
;; tests pass a recorder. Returns whatever the transport returns.
(define otel/post-otlp
(fn (endpoint spans transport)
(transport
{:method "POST"
:url endpoint
:headers {:content-type "application/json"}
:body (otel/export-otlp-json spans)})))