code wiki / (root) / nx_trace_emit.nx

nx_trace_emit.nx source

↩ module page · 212 lines · 7754 B

1// nx_trace_emit.nx -- OpenTelemetry-style span emission for the 2// substrate. Cures the black-box critique: every decision becomes 3// one line of tail-able JSONL. 4// 5// 2025-2026 LLM-observability consensus (LangWatch / Phoenix / 6// Helicone): OpenTelemetry-native traces with nested spans per 7// decision, trace_id + session_id linking sibling operations. We 8// emit a deliberately small JSON shape so this works without an 9// external trace collector: 10// 11// {"trace":T,"span":S,"parent":P,"kind":K,"start_ms":A,"end_ms":B, 12// "attrs":{"k0":v0,"k1":v1,...}} 13// 14// Fields are written using direct sys_write to a configured fd. 15// Default fd = 2 (stderr) so the host process can pipe traces to 16// a log file without interfering with stdout (which generators may 17// still use for image bytes / prompts / etc.). Per-trace fd is 18// settable via NX_TRACE_FD (sealed below). 19// 20// genealogy_id: opentelemetry_traces + langwatch_2025 21// lineage_id: substrate_native_trace_v1 22 23// nx_safety_envelope: 24// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 25// sil_target: SIL1 26// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 27// verdict: NOT_YET_EVALUATED 28 29import "nx_syscalls.nx" 30import "nx_runtime.nx" 31import "nx_tier.nx" 32 33// ===== Sealed-enum: SpanKind ====================================== 34// 35// Closed taxonomy of what kind of operation a span represents. The 36// audit pipeline filters / colors / groups by this field. 37 38const NX_SPAN_BATCH_OPEN: nx_int = 0 // top-level batch root 39const NX_SPAN_ARC_PICK: nx_int = 1 40const NX_SPAN_OUTFIT_PICK: nx_int = 2 41const NX_SPAN_LOCATION_PICK: nx_int = 3 42const NX_SPAN_POSE_PICK: nx_int = 4 43const NX_SPAN_AFFECT_PICK: nx_int = 5 44const NX_SPAN_CAMERA_PICK: nx_int = 6 45const NX_SPAN_RENDER_EMIT: nx_int = 7 46const NX_SPAN_VERIFY_COMMIT: nx_int = 8 // post-render verdict 47const NX_SPAN_CONTINUITY_CHECK: nx_int = 9 48const NX_SPAN_POOL_UTIL: nx_int = 10 49const NX_SPAN_KEYNESS_AUDIT: nx_int = 11 50const NX_SPAN_PROMPT_ASSEMBLE: nx_int = 12 51const NX_SPAN_BATCH_CLOSE: nx_int = 13 52const NX_SPAN_N_KINDS: nx_int = 14 53 54func nx_span_kind_is_valid(k: nx_int) -> nx_int { 55 if k < 0 { return 0 } 56 if k >= NX_SPAN_N_KINDS { return 0 } 57 return 1 58} 59 60// ===== Output fd ================================================== 61// 62// Globally configurable so the host can redirect traces. Default 63// fd 2 (stderr). Caller sets via nx_trace_set_fd; sealed bounds. 64 65const NX_TRACE_FD_DEFAULT: nx_int = 2 66const NX_TRACE_FD_MAX: nx_int = 1024 67 68// Module-private fd holder. i64 cell at fixed mmap; per-process. 69 70struct TraceCfg { 71 fd: nx_int, 72 next_span_id: nx_int, 73 enabled: nx_int 74} 75 76const NX_TRACECFG_BYTES: nx_int = 24 77 78// Singleton allocator. Caller invokes once at startup; subsequent 79// calls return the same pointer via re-init of the in-place block. 80// We don't track a true singleton (NishiLang has no global mutable), 81// so the caller must thread the *TraceCfg through their pipeline. 82 83func nx_trace_cfg_alloc() -> *TraceCfg { 84 let cfg: *TraceCfg = (sys_mmap(NX_TRACECFG_BYTES)) as *TraceCfg 85 cfg.fd = NX_TRACE_FD_DEFAULT 86 cfg.next_span_id = 1 87 cfg.enabled = 1 88 return cfg 89} 90 91func nx_trace_set_fd(cfg: *TraceCfg, fd: nx_int) -> nx_int { 92 if fd < 0 { return 0 - 1 } 93 if fd >= NX_TRACE_FD_MAX { return 0 - 1 } 94 cfg.fd = fd 95 return 0 96} 97 98func nx_trace_disable(cfg: *TraceCfg) -> nx_int { 99 cfg.enabled = 0 100 return 0 101} 102 103func nx_trace_enable(cfg: *TraceCfg) -> nx_int { 104 cfg.enabled = 1 105 return 0 106} 107 108// ===== Span ID minting ============================================ 109// 110// Trace IDs are caller-supplied (typically one per batch). Span IDs 111// are minted per-cfg via a monotonic counter; cheap, sufficient for 112// session-local traces. For distributed deployments swap to ULID 113// or seven-byte random (queued). 114 115func nx_trace_mint_span_id(cfg: *TraceCfg) -> nx_int { 116 let id: nx_int = cfg.next_span_id 117 cfg.next_span_id = cfg.next_span_id + 1 118 return id 119} 120 121// ===== Direct-write helpers ======================================= 122// 123// Hand-emit JSON because we don't want a stringly-typed writer here; 124// the schema is tiny and stable. 125 126func _trace_write_raw(cfg: *TraceCfg, buf: *u8, n: nx_int) -> nx_int { 127 if cfg.enabled == 0 { return 0 } 128 return sys_write(cfg.fd, buf, n) 129} 130 131func _trace_write_z(cfg: *TraceCfg, s: *u8) -> nx_int { 132 if cfg.enabled == 0 { return 0 } 133 return sys_write(cfg.fd, s, strlen(s)) 134} 135 136func _trace_write_i64(cfg: *TraceCfg, n: nx_int) -> nx_int { 137 if cfg.enabled == 0 { return 0 } 138 let buf: *u8 = sys_mmap(32) 139 let len: nx_int = itoa(n, buf) 140 return sys_write(cfg.fd, buf, len) 141} 142 143// ===== Public: emit one span ====================================== 144// 145// Emits a single JSONL span. attrs is a flat [k_hash, v] pair 146// buffer of length 2*n_attrs (FNV-1a hashes for keys keeps the wire 147// shape numeric; tooling can resolve back to names via a separate 148// name table). attr values are i64; floating-point not supported on 149// the wire (everything is Q10 fixed-point on this substrate). 150 151func nx_trace_emit_span(cfg: *TraceCfg, 152 trace_id: nx_int, span_id: nx_int, 153 parent_span_id: nx_int, kind: nx_int, 154 start_ms: nx_int, end_ms: nx_int, 155 attrs: *i64, n_attrs: nx_int) -> nx_int { 156 if cfg.enabled == 0 { return 0 } 157 _trace_write_z(cfg, "{\"trace\":" as *u8) 158 _trace_write_i64(cfg, trace_id) 159 _trace_write_z(cfg, ",\"span\":" as *u8) 160 _trace_write_i64(cfg, span_id) 161 _trace_write_z(cfg, ",\"parent\":" as *u8) 162 _trace_write_i64(cfg, parent_span_id) 163 _trace_write_z(cfg, ",\"kind\":" as *u8) 164 _trace_write_i64(cfg, kind) 165 _trace_write_z(cfg, ",\"start_ms\":" as *u8) 166 _trace_write_i64(cfg, start_ms) 167 _trace_write_z(cfg, ",\"end_ms\":" as *u8) 168 _trace_write_i64(cfg, end_ms) 169 _trace_write_z(cfg, ",\"attrs\":[" as *u8) 170 var i: nx_int = 0 171 while i < n_attrs { 172 if i > 0 { _trace_write_z(cfg, "," as *u8) } 173 _trace_write_z(cfg, "[" as *u8) 174 _trace_write_i64(cfg, attrs[2 * i]) 175 _trace_write_z(cfg, "," as *u8) 176 _trace_write_i64(cfg, attrs[2 * i + 1]) 177 _trace_write_z(cfg, "]" as *u8) 178 i = i + 1 179 } 180 _trace_write_z(cfg, "]}\n" as *u8) 181 return 0 182} 183 184// ===== Convenience: zero-attr span ================================ 185// 186// For decisions where the type alone is enough (e.g. BATCH_OPEN). 187 188func nx_trace_emit_simple(cfg: *TraceCfg, 189 trace_id: nx_int, span_id: nx_int, 190 parent_span_id: nx_int, kind: nx_int, 191 start_ms: nx_int, end_ms: nx_int) -> nx_int { 192 let empty: *i64 = (sys_mmap(8)) as *i64 193 return nx_trace_emit_span(cfg, trace_id, span_id, parent_span_id, 194 kind, start_ms, end_ms, empty, 0) 195} 196 197// ===== One-pair convenience ======================================= 198// 199// Most decisions carry exactly one numeric attribute (the chosen ID 200// or verdict). Skip the buffer dance. 201 202func nx_trace_emit_one_attr(cfg: *TraceCfg, 203 trace_id: nx_int, span_id: nx_int, 204 parent_span_id: nx_int, kind: nx_int, 205 start_ms: nx_int, end_ms: nx_int, 206 key_hash: nx_int, value: nx_int) -> nx_int { 207 let attrs: *i64 = (sys_mmap(16)) as *i64 208 attrs[0] = key_hash 209 attrs[1] = value 210 return nx_trace_emit_span(cfg, trace_id, span_id, parent_span_id, 211 kind, start_ms, end_ms, attrs, 1) 212}