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}