@swfte/nexus-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/health.cjs ADDED
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+ /**
3
+ * `service_health` — derived from spans we already have, on the path that already exists.
4
+ *
5
+ * A port of `nexus/health.py`. Availability, latency and error rate are what an operator asks for
6
+ * first, and the tempting way to get them is a new instrumentation ask: a `nexus.request()` wrapper
7
+ * threaded through every handler. That ask is why most observability adoptions stall, and it is
8
+ * unnecessary — the SDK already brackets every `action` span and already knows how long each took
9
+ * and whether it ended in an error. This module reads those spans on their way out and rolls them
10
+ * up.
11
+ *
12
+ * ── What is measured, stated plainly, because the wire key invites a stronger reading ─────────
13
+ *
14
+ * `requests` on this event, when the SDK is the producer, is **the number of action spans
15
+ * observed** — not the number of HTTP requests served. An application that instruments one action
16
+ * per request makes the two identical; one that instruments three makes them differ by 3×; one that
17
+ * instruments none emits nothing at all. Which is the reason for the next rule.
18
+ *
19
+ * **An empty window emits nothing.** Zero observed spans does not mean zero requests — far more
20
+ * often it means "this service never calls `run.action`". Writing `requests: 0` there would be a
21
+ * fabricated zero, and it would read on a console as a measured outage. `heartbeat()` is the honest
22
+ * version of that statement: a window with a timestamp and no quantities, which says *this process
23
+ * was alive and nothing was measured*.
24
+ *
25
+ * **Percentiles** are exact within the reservoir, which holds the most recent {@link RESERVOIR}
26
+ * durations of the window. `requests` and `errors` are always true counts. The contract carries no
27
+ * sample-count field, so the approximation is recorded here rather than on the wire — at the default
28
+ * 60 s window a service would need to sustain ~136 spans/second before the cap binds at all.
29
+ *
30
+ * ── Two things in the Python that deliberately did NOT come across ───────────────────────────
31
+ *
32
+ * 1. **The lock.** `HealthRollup` in Python holds a `threading.Lock`, and `roll()` acquires it
33
+ * non-blockingly so that a re-entrant shutdown (atexit hook running when SIGTERM arrives) cannot
34
+ * deadlock the process against itself. Node has one event loop and no preemption, so a
35
+ * synchronous `roll()` cannot be interleaved with `observe()` at all — there is no torn read to
36
+ * prevent, and adding a mutex would be porting the shape of a fix rather than its reason.
37
+ * Re-entrancy is still guarded, because the shutdown path *is* re-entrant here too: `_rolling`
38
+ * below does what the non-blocking acquire did, for the same reason (emitting one window twice
39
+ * would double-count it).
40
+ * 2. **The worker/caller split.** Python aggregates only when its background thread is draining, so
41
+ * an explicit `flush()` from the application's thread drains *without* aggregating — which keeps
42
+ * "the calling thread never performs I/O, and now also never aggregates" true without a hole.
43
+ * Node has no such split: the flush is asynchronous either way, so the fold happens in a promise
44
+ * continuation on the loop regardless of who asked. Aggregating on every drain is therefore
45
+ * simpler and slightly *more* complete than Python, which silently drops spans drained by an
46
+ * explicit flush. Noted in PARITY.md rather than left to be discovered from a count that differs.
47
+ */
48
+
49
+ /**
50
+ * Most recent durations retained per window for the percentile calculation. Bounded because a
51
+ * window is wall-clock-bounded rather than volume-bounded, and an unbounded array here would be a
52
+ * memory leak with a traffic spike as its trigger.
53
+ */
54
+ const RESERVOIR = 8192;
55
+
56
+ /**
57
+ * The span type we derive from. One name, in one place: the day `tool_action` is renamed on the
58
+ * wire, this file must fail loudly rather than silently roll up nothing forever.
59
+ */
60
+ const SPAN_TYPE = 'tool_action';
61
+
62
+ /**
63
+ * Nearest-rank percentile. `null` for an empty sample — never `0`, which is a latency.
64
+ *
65
+ * `rank = ceil(q * n)`, the textbook definition, so a p95 is an observation that was actually in
66
+ * the sample rather than an interpolation between two. An interpolated p99 over twenty requests
67
+ * invents a latency nobody experienced.
68
+ */
69
+ function pct(sortedMs, q) {
70
+ const n = sortedMs.length;
71
+ if (n === 0) return null;
72
+ const scaled = q * n;
73
+ let rank = Math.floor(scaled);
74
+ if (scaled > rank) rank += 1;
75
+ rank = Math.max(1, Math.min(n, rank));
76
+ return Math.round(sortedMs[rank - 1] * 1000) / 1000;
77
+ }
78
+
79
+ class HealthRollup {
80
+ constructor(now) {
81
+ this._now = now;
82
+ this._durations = [];
83
+ this._requests = 0;
84
+ this._errors = 0;
85
+ this._windowFrom = now();
86
+ this._lastRoll = Date.now();
87
+ this._rolling = false;
88
+ }
89
+
90
+ /**
91
+ * Fold one outgoing batch into the current window. Called on the drain path, over events that
92
+ * are on their way out anyway, so an action span costs exactly what it cost before this file
93
+ * existed.
94
+ */
95
+ observe(batch) {
96
+ let seen = 0;
97
+ let errs = 0;
98
+ const durations = [];
99
+ for (const e of batch) {
100
+ if (!e || e.type !== SPAN_TYPE) continue;
101
+ seen += 1;
102
+ // The tiered ladder means the error may present as `error`, `error_preview`, or only as
103
+ // `error_fingerprint` at `metadata_only`. The fingerprint is the one that survives every
104
+ // tier, so an error rate must not depend on how much content the operator allows — a
105
+ // `metadata_only` deployment reporting zero errors would be the worst kind of wrong.
106
+ if (e.error_fingerprint !== undefined || e.error !== undefined) errs += 1;
107
+ const d = e.duration_ms;
108
+ if (typeof d === 'number' && Number.isFinite(d)) durations.push(d);
109
+ }
110
+ if (!seen) return;
111
+ this._requests += seen;
112
+ this._errors += errs;
113
+ for (const d of durations) {
114
+ this._durations.push(d);
115
+ if (this._durations.length > RESERVOIR) this._durations.shift();
116
+ }
117
+ }
118
+
119
+ due(intervalMs) {
120
+ if (!intervalMs || intervalMs <= 0) return false;
121
+ return (Date.now() - this._lastRoll) >= intervalMs;
122
+ }
123
+
124
+ /**
125
+ * Close the window and build one `service_health` event, or `null`.
126
+ *
127
+ * `emptyWindow` is `heartbeat()`'s path: emit a window even when nothing was in it, so a process
128
+ * with no request loop can still say it is alive. Every quantity stays absent in that case — the
129
+ * event carries a timestamp and no numbers, which is precisely the claim.
130
+ */
131
+ roll(sessionId, cfg, builders, emptyWindow) {
132
+ if (this._rolling) return null; // re-entered during shutdown; already rolling
133
+ this._rolling = true;
134
+ let requests;
135
+ let errors;
136
+ let samples;
137
+ let windowFrom;
138
+ try {
139
+ requests = this._requests;
140
+ errors = this._errors;
141
+ samples = this._durations.slice().sort((a, b) => a - b);
142
+ windowFrom = this._windowFrom;
143
+ this._durations = [];
144
+ this._requests = 0;
145
+ this._errors = 0;
146
+ this._windowFrom = this._now();
147
+ this._lastRoll = Date.now();
148
+ } finally {
149
+ this._rolling = false;
150
+ }
151
+
152
+ if (requests === 0 && !emptyWindow) return null;
153
+ const measured = requests > 0;
154
+
155
+ return builders.serviceHealth(sessionId, cfg, {
156
+ service: cfg.service,
157
+ appId: cfg.application,
158
+ env: cfg.env !== 'unknown' ? cfg.env : null,
159
+ windowFrom,
160
+ windowTo: this._now(),
161
+ // A window we watched and saw nothing in is a measured zero; a window we did not watch (a
162
+ // heartbeat) has no count at all. Two different facts, two renderings.
163
+ requests: measured ? requests : null,
164
+ errors: measured ? errors : null,
165
+ p50Ms: measured ? pct(samples, 0.50) : null,
166
+ p95Ms: measured ? pct(samples, 0.95) : null,
167
+ p99Ms: measured ? pct(samples, 0.99) : null,
168
+ });
169
+ }
170
+ }
171
+
172
+ module.exports = { HealthRollup, RESERVOIR, SPAN_TYPE, pct };
package/src/index.cjs ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ /**
3
+ * nexus — CommonJS entry.
4
+ *
5
+ * const nexus = require('nexus');
6
+ *
7
+ * This is a thin re-export of the same `core.cjs` the ESM entry pulls in, so `import` and
8
+ * `require` of this package in one process share one client, one session id and one queue.
9
+ * `test/dual.test.mjs` asserts that; the dual-package hazard is otherwise silent and shows up
10
+ * downstream as one service reporting as two.
11
+ */
12
+ const ENABLED = (() => {
13
+ const v = process.env.NEXUS_ENABLED;
14
+ if (v === undefined) return true;
15
+ return !['0', 'false', 'no', 'off'].includes(String(v).trim().toLowerCase());
16
+ })();
17
+
18
+ const INERT = new Proxy(function () {}, {
19
+ get(_t, p) { return p === 'then' ? undefined : INERT; },
20
+ apply() { return INERT; },
21
+ construct() { return INERT; },
22
+ });
23
+
24
+ let _core = null;
25
+ function core() {
26
+ if (_core === null) _core = require('./core.cjs');
27
+ return _core;
28
+ }
29
+
30
+ module.exports = {
31
+ init: (o) => (ENABLED ? core().init(o) : null),
32
+ agent: (n, o) => (ENABLED ? core().agent(n, o) : INERT),
33
+ withAgent: (n, o, f) => (ENABLED ? core().withAgent(n, o, f)
34
+ : Promise.resolve((typeof o === 'function' ? o : f)(INERT))),
35
+ action: (n, t) => (ENABLED ? core().action(n, t) : INERT),
36
+ currentRun: () => (ENABLED ? core().currentRun() : null),
37
+ flush: (d) => (ENABLED ? core().flush(d) : Promise.resolve(true)),
38
+ shutdown: (d) => (ENABLED ? core().shutdown(d) : Promise.resolve(true)),
39
+
40
+ // ── operate plane (ANCHOR-INTEGRATION §6.1–6.4) ──────────────────────────────────────────
41
+ deployment: (o) => (ENABLED ? core().deployment(o) : false),
42
+ integration: (n, o) => (ENABLED ? core().integration(n, o) : INERT),
43
+ withIntegration: (n, o, f) => (ENABLED ? core().withIntegration(n, o, f)
44
+ : Promise.resolve((typeof o === 'function' ? o : f)(INERT))),
45
+ expectsData: (n, o) => (ENABLED ? core().expectsData(n, o) : false),
46
+ heartbeat: () => (ENABLED ? core().heartbeat() : false),
47
+ setWaitUntil: (f) => { if (ENABLED) core().setWaitUntil(f); },
48
+ instrumentHandler: (h, o) => (ENABLED ? core().instrumentHandler(h, o) : h),
49
+ counters: () => (ENABLED ? core().counters() : {}),
50
+ enabled: () => ENABLED,
51
+ instrumentation: () => (ENABLED ? core().describeInstrumentation() : 'none'),
52
+ version: '0.1.0',
53
+ };
package/src/index.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * nexus — agent governance as an application dependency. ESM entry.
3
+ *
4
+ * import * as nexus from 'nexus';
5
+ * nexus.init({ service: 'support-triage', env: 'prod' });
6
+ *
7
+ * const run = nexus.agent('triage', { goalClass: 'classify' });
8
+ * const act = run.action('db.write', 'tickets');
9
+ * act.effect({ rows: n }).end();
10
+ * run.outcome('resolved', { verifiedBy: 'test' });
11
+ * run.end();
12
+ *
13
+ * `NEXUS_ENABLED=0` is a true kill switch: no hooks registered, no `Module._load` patch, no exit
14
+ * handler, no file opened. Every export below short-circuits on `ENABLED`.
15
+ *
16
+ * Python's `nexus/__init__.py` goes one step further and defers even the *import* of its core,
17
+ * because on CPython `import typing` alone costs tens of milliseconds of a Lambda's cold start.
18
+ * That does not translate and does not need to: `core.cjs` pulls in three builtins that are already
19
+ * in Node's startup snapshot, and `bundle/measure.mjs` puts the switched-off import at well under a
20
+ * millisecond. The static import is also the only form a bundler can follow — a
21
+ * `createRequire(import.meta.url)('./core.cjs')` resolves against the *bundle's* location at
22
+ * runtime and throws, which would make this package unusable in exactly the deployment shape
23
+ * SCOPE.md §3 says we have to serve.
24
+ */
25
+ import core_ from './core.cjs';
26
+
27
+ const ENABLED = (() => {
28
+ const v = process.env.NEXUS_ENABLED;
29
+ if (v === undefined) return true;
30
+ return !['0', 'false', 'no', 'off'].includes(String(v).trim().toLowerCase());
31
+ })();
32
+
33
+ function core() { return core_; }
34
+
35
+ /** Absorbs every use when the SDK is switched off, so host code needs no `if (nexus)` guards. */
36
+ const INERT = new Proxy(function () {}, {
37
+ get(_t, p) { return p === 'then' ? undefined : INERT; },
38
+ apply() { return INERT; },
39
+ construct() { return INERT; },
40
+ });
41
+
42
+ /**
43
+ * Declare service identity and arm the SDK. Idempotent — safe to call from both `main()` and a
44
+ * framework startup hook.
45
+ * @param {{service?: string, env?: string, version?: string, tier?: 'metadata_only'|'hashed'|'full',
46
+ * sink?: string, tags?: Record<string,string>, enabled?: boolean}} [opts]
47
+ */
48
+ export function init(opts) { return ENABLED ? core().init(opts) : null; }
49
+
50
+ /**
51
+ * Open a run: one unit of agent work, with an outcome. Call `run.end()` when it finishes.
52
+ * @param {string} name
53
+ * @param {{goalClass?: string}} [opts]
54
+ */
55
+ export function agent(name, opts) { return ENABLED ? core().agent(name, opts) : INERT; }
56
+
57
+ /**
58
+ * Scoped run. Prefer this in async code: the current run propagates across `await` correctly here
59
+ * and only here (`AsyncLocalStorage`), which the object form returned by `agent()` cannot do.
60
+ * @template T
61
+ * @param {string} name
62
+ * @param {{goalClass?: string}|((run: any) => Promise<T>)} optsOrFn
63
+ * @param {(run: any) => Promise<T>} [fn]
64
+ * @returns {Promise<T>}
65
+ */
66
+ export function withAgent(name, optsOrFn, fn) {
67
+ if (!ENABLED) {
68
+ const f = typeof optsOrFn === 'function' ? optsOrFn : fn;
69
+ return Promise.resolve(f(INERT));
70
+ }
71
+ return core().withAgent(name, optsOrFn, fn);
72
+ }
73
+
74
+ /**
75
+ * Open an action against the run currently in context. For code several frames below the
76
+ * `agent()` call that opened the run.
77
+ * @param {string} name
78
+ * @param {string} [target]
79
+ */
80
+ export function action(name, target) { return ENABLED ? core().action(name, target) : INERT; }
81
+
82
+ /** The run currently in context, or `null`. */
83
+ export function currentRun() { return ENABLED ? core().currentRun() : null; }
84
+
85
+ /**
86
+ * Drain the queue within a deadline. Call before a process that is about to stop.
87
+ *
88
+ * Asynchronous, because the calling thread never performs I/O — there is no synchronous form to
89
+ * offer. Switched off, it resolves `true` without touching the core.
90
+ * @param {number} [deadlineMs]
91
+ * @returns {Promise<boolean>}
92
+ */
93
+ export function flush(deadlineMs) { return ENABLED ? core().flush(deadlineMs) : Promise.resolve(true); }
94
+
95
+ /** Final flush and stop. @param {number} [deadlineMs] @returns {Promise<boolean>} */
96
+ export function shutdown(deadlineMs) {
97
+ return ENABLED ? core().shutdown(deadlineMs) : Promise.resolve(true);
98
+ }
99
+
100
+ // ── operate plane (ANCHOR-INTEGRATION §6.1–6.4) ─────────────────────────────────────────────
101
+
102
+ /** Self-report a deployment. Stamped `detected_by: 'self'`, the weakest claim on the plane. */
103
+ export function deployment(opts) { return ENABLED ? core().deployment(opts) : false; }
104
+
105
+ /** Open a probe of an outbound dependency. Liveness and freshness stay separate. */
106
+ export function integration(name, opts) { return ENABLED ? core().integration(name, opts) : INERT; }
107
+
108
+ /** The bracketed form of {@link integration}. Closes on the way out, including on throw. */
109
+ export function withIntegration(name, optsOrFn, fn) {
110
+ if (!ENABLED) {
111
+ const f = typeof optsOrFn === 'function' ? optsOrFn : fn;
112
+ return Promise.resolve(f(INERT));
113
+ }
114
+ return core().withIntegration(name, optsOrFn, fn);
115
+ }
116
+
117
+ /** Declare that data is expected within a window, so its absence can raise an alarm. */
118
+ export function expectsData(name, opts) { return ENABLED ? core().expectsData(name, opts) : false; }
119
+
120
+ /**
121
+ * Close the `service_health` window now and emit it, even when nothing was measured.
122
+ *
123
+ * The honest form of "this process is alive": a window with a timestamp and no quantities. A
124
+ * `requests: 0` would be a fabricated zero, and zero observed spans far more often means "this
125
+ * service never calls `run.action`" than it means an outage.
126
+ */
127
+ export function heartbeat() { return ENABLED ? core().heartbeat() : false; }
128
+
129
+ /** Give the SDK the host's `waitUntil` (Vercel), so a flush can outlive the response. */
130
+ export function setWaitUntil(fn) { if (ENABLED) core().setWaitUntil(fn); }
131
+
132
+ /**
133
+ * Wrap a serverless handler so the queue drains before the sandbox freezes.
134
+ *
135
+ * Without this — or `init({ waitUntil })` — the last buffered events of every invocation are
136
+ * lost, not delayed. See `src/core.cjs`, the serverless section.
137
+ */
138
+ export function instrumentHandler(handler, opts) {
139
+ return ENABLED ? core().instrumentHandler(handler, opts) : handler;
140
+ }
141
+
142
+ /** The SDK's account of itself: drops, sink failures, contained errors. */
143
+ export function counters() { return ENABLED ? core().counters() : {}; }
144
+
145
+ /** `true` unless `NEXUS_ENABLED` says otherwise. */
146
+ export function enabled() { return ENABLED; }
147
+
148
+ /** Which loader armed auto-instrumentation: `esm-hooks`, `cjs-require`, or `none`. */
149
+ export function instrumentation() { return ENABLED ? core().describeInstrumentation() : 'none'; }
150
+
151
+ export const version = '0.1.0';
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+ /**
3
+ * Span → nexus event. A port of the emitting half of `nexus/otel/bridge.py`.
4
+ *
5
+ * This is what makes `model_response` and `model_thinking` reachable in Node, and it is the only
6
+ * path by which model-authored text ever leaves this SDK — the AI SDK bridge deliberately emits
7
+ * none, because before this module existed there was nowhere honest to put it.
8
+ *
9
+ * ── What is ported, and what is not ──────────────────────────────────────────────────────────
10
+ *
11
+ * Ported: the reader (`semconv.cjs`), the classifier (`classify.cjs`), and one span → its events.
12
+ *
13
+ * **Not ported: the logical-call join.** Python's bridge maintains a tree of open spans so that an
14
+ * instrumentation emitting one span per HTTP *attempt* — a retry, a resend, a streamed reconnect —
15
+ * is billed once rather than three times, with the losing observations recorded as shape. That is a
16
+ * whole subsystem (`integrations/billing.py`, span eviction, GC finalizers for abandoned spans) and
17
+ * porting half of it would be worse than not porting it: a half-built join produces a *plausible*
18
+ * number, and a plausible wrong bill is harder to catch than an obviously missing one.
19
+ *
20
+ * So this bridge is **one span, one observation**. The consequence is named rather than buried:
21
+ * against an instrumentation that emits a span per retry, token counts and cost are summed across
22
+ * attempts instead of deduplicated. `attempts` is still read and carried when the instrumentation
23
+ * reports it, so the over-count is at least visible in the record. PARITY.md §6 states this, and it
24
+ * is the first thing to build if this bridge is taken further.
25
+ *
26
+ * ── Content ─────────────────────────────────────────────────────────────────────────────────
27
+ *
28
+ * Upstream redaction is **not trusted**. Every instrumentation in this space has its own
29
+ * content-capture switch and its own idea of what a secret looks like; some have none. Whether text
30
+ * may leave this process is our customer's tier decision, so it is re-decided here against the same
31
+ * ladder every other free-text field passes through. Even `full` is redacted: a tier is a decision
32
+ * about content, never a waiver on credentials.
33
+ *
34
+ * The prompt is read and **emitted nowhere**, matching Python. `SpanFacts.inputText` exists so the
35
+ * reader is complete and so a future `prompt` event has something to draw on; there is no `prompt`
36
+ * builder in `nexus_devtools/events.py` that this SDK could emit against, and inventing one is the
37
+ * mistake this repository has already made once with `data_expectation`.
38
+ */
39
+
40
+ const semconv = require('./semconv.cjs');
41
+ const classify = require('./classify.cjs');
42
+
43
+ /** Which semconv release produced these facts. Rides on the event so a reader can tell which
44
+ * vocabulary a row was extracted under — the spec is pre-stable and will move. */
45
+ const SEMCONV_TAG = Object.freeze({
46
+ genai: semconv.GENAI_SEMCONV_VERSION,
47
+ stability: semconv.GENAI_SEMCONV_STABILITY,
48
+ });
49
+
50
+ class Bridge {
51
+ /**
52
+ * @param {object} core the SDK core (injected so this module has no cycle back into it)
53
+ * @param {{strict?: boolean}} [opts]
54
+ */
55
+ constructor(core, opts) {
56
+ this.core = core;
57
+ this.strict = opts && opts.strict !== undefined ? opts.strict : null;
58
+ this._stats = { seen: 0, emitted: 0, ignored: 0, unclassified: 0 };
59
+ }
60
+
61
+ /** Statistics for tests and for `counters()`. */
62
+ stats() { return Object.assign({}, this._stats); }
63
+
64
+ reset() { this._stats = { seen: 0, emitted: 0, ignored: 0, unclassified: 0 }; }
65
+
66
+ /**
67
+ * Read one finished span and emit whatever it is worth.
68
+ *
69
+ * Never throws. A bridge that raises has put an exception on the host's request path from inside
70
+ * a span exporter, which is the one place a telemetry failure is guaranteed to be blamed on the
71
+ * application.
72
+ */
73
+ ingest(span) {
74
+ // Strict mode deliberately bypasses the guard. `guard` exists so a bridge bug cannot reach the
75
+ // host, which is exactly right in production — but under test the whole point of strict is that
76
+ // an unclassified span *fails the build*, and a guarded throw is a silently contained one. So
77
+ // the two modes get the two opposite failure behaviours the classifier's header describes.
78
+ if (this._strictNow()) return this._ingest(span);
79
+ return this.core.guard('otel.ingest', () => this._ingest(span), false);
80
+ }
81
+
82
+ _strictNow() {
83
+ return this.strict === null || this.strict === undefined
84
+ ? classify.strictDefault()
85
+ : Boolean(this.strict);
86
+ }
87
+
88
+ _ingest(span) {
89
+ this._stats.seen += 1;
90
+ const facts = semconv.normalise(span);
91
+
92
+ if (!classify.isOurs(facts)) {
93
+ // Not a GenAI span at all — an HTTP call, a DB query. Not ours, and silence is correct:
94
+ // counting every unrelated span in a customer's tracer as "ignored" would make the number
95
+ // meaningless.
96
+ this._stats.ignored += 1;
97
+ return false;
98
+ }
99
+
100
+ let cls;
101
+ try {
102
+ cls = classify.classifySpan(facts, this.strict);
103
+ } catch (err) {
104
+ // Strict mode. Re-thrown deliberately: under test a new span kind must fail the build.
105
+ this._stats.unclassified += 1;
106
+ this.core.incr('bridge_unclassified');
107
+ throw err;
108
+ }
109
+
110
+ if (cls === null) {
111
+ // A GenAI span whose kind we do not model. **Dropped, and counted** — never defaulted into
112
+ // `behavior_trace`, which is the class the product sells as evidence. The counter is what
113
+ // keeps this a visible coverage gap rather than an invisible one.
114
+ this._stats.unclassified += 1;
115
+ this.core.incr('bridge_unclassified');
116
+ this.core.incr('bridge_unclassified.' + (facts.kind || 'unknown'));
117
+ return false;
118
+ }
119
+
120
+ const client = this.core.ensureClient();
121
+ if (!client) return false;
122
+
123
+ if (facts.kind === semconv.KIND_LLM) this._emitLlm(client, facts);
124
+ else this._emitTool(client, facts);
125
+
126
+ this._stats.emitted += 1;
127
+ return true;
128
+ }
129
+
130
+ _emitLlm(client, facts) {
131
+ const cfg = client.cfg;
132
+ const run = this.core.currentRun();
133
+ const runId = run ? run.runId : undefined;
134
+
135
+ // Only emit usage when there is usage. A `token_usage` row of zeros for a span that carried no
136
+ // accounting is a fabricated measurement, and the same fabricated-zero rule that governs
137
+ // `service_health` governs this.
138
+ if (semconv.hasUsage(facts) || facts.reportedCostUsd !== null) {
139
+ const [costUsd, costSource] = this._cost(facts);
140
+ const ev = this.core.contract.tokenUsage(client.sessionId, cfg, {
141
+ model: facts.model || 'unknown',
142
+ provider: facts.provider || undefined,
143
+ inputTokens: facts.inputTokens || 0,
144
+ outputTokens: facts.outputTokens || 0,
145
+ cacheReadTokens: facts.cacheReadTokens === null ? undefined : facts.cacheReadTokens,
146
+ cacheWriteTokens: this._cacheWriteTotal(facts),
147
+ costUsd: costUsd === null ? undefined : costUsd,
148
+ costSource: costSource === null ? undefined : costSource,
149
+ runId,
150
+ attempts: facts.attempts === null ? undefined : facts.attempts,
151
+ instrumentation: 'otel',
152
+ });
153
+ // Which vocabulary this was read under, and the 1h cache split the contract has no field for.
154
+ // Additive on a type the schema declares `additionalProperties: true`, and structure rather
155
+ // than content — no value from the span reaches it.
156
+ ev.bridge = Object.assign({ semconv: SEMCONV_TAG }, {
157
+ vocabulary: facts.vocabulary,
158
+ cache_write_1h_tokens: facts.cacheWrite1hTokens === null
159
+ ? undefined : facts.cacheWrite1hTokens,
160
+ });
161
+ client.emit(ev);
162
+ }
163
+
164
+ this._emitContent(client, cfg, facts, runId);
165
+ }
166
+
167
+ /**
168
+ * Model-authored text, re-gated by *our* tier and split by epistemic class.
169
+ *
170
+ * The split is the point. A completion is `interaction_narrative` — what the user was told. A
171
+ * reasoning trace is `rationalisation` — what the model said about its own process. Folding the
172
+ * second into the first would launder a claim into the record beside the answer it is supposed
173
+ * to justify.
174
+ */
175
+ _emitContent(client, cfg, facts, runId) {
176
+ const out = this._fragment(facts.outputText, cfg);
177
+ if (out) {
178
+ client.emit(this.core.base('model_response', client.sessionId, cfg,
179
+ classify.classifyPayload(classify.PAYLOAD_OUTPUT), {
180
+ model: facts.model, provider: facts.provider, prompt_id: runId,
181
+ answer: out.text !== undefined ? out.text : out.preview,
182
+ answer_chars: out.chars,
183
+ answer_fingerprint: out.fingerprint,
184
+ tokens_est: out.tokens_est,
185
+ redacted: out.text_redacted || out.preview_redacted || null,
186
+ truncated: out.preview_truncated || null,
187
+ }));
188
+ }
189
+
190
+ const think = this._fragment(facts.reasoningText, cfg);
191
+ if (think) {
192
+ client.emit(this.core.base('model_thinking', client.sessionId, cfg,
193
+ classify.classifyPayload(classify.PAYLOAD_REASONING), {
194
+ model: facts.model, provider: facts.provider, prompt_id: runId,
195
+ thinking: think.text !== undefined ? think.text : think.preview,
196
+ thinking_chars: think.chars,
197
+ thinking_fingerprint: think.fingerprint,
198
+ redacted: think.text_redacted || think.preview_redacted || null,
199
+ truncated: think.preview_truncated || null,
200
+ }));
201
+ }
202
+ }
203
+
204
+ /** The tier gate, applied on ingest, because upstream redaction is not trusted. */
205
+ _fragment(text, cfg) {
206
+ if (!text) return null;
207
+ return this.core.redactPreview(text, cfg.tier);
208
+ }
209
+
210
+ _emitTool(client, facts) {
211
+ const run = this.core.currentRun();
212
+ const runId = run ? run.runId : undefined;
213
+
214
+ // `target` is an identifier or nothing — `semconv.identifier` guarantees it, and that is the
215
+ // reason this call no longer hands the model's own arguments to a text gate. What the
216
+ // arguments contributed that was worth keeping — how many, named what, of what types — rides
217
+ // in `effect.arg_shape`, which carries no values at any depth.
218
+ const effect = { incomplete: false };
219
+ if (facts.toolArgShape) effect.arg_shape = facts.toolArgShape;
220
+ if (facts.vocabulary) effect.vocabulary = facts.vocabulary;
221
+
222
+ client.emit(this.core.contract.toolAction(client.sessionId, client.cfg, {
223
+ toolName: facts.toolName || facts.name || 'tool',
224
+ action: 'invoke',
225
+ target: facts.toolTarget === null ? undefined : facts.toolTarget,
226
+ durationMs: facts.durationMs === null ? undefined : facts.durationMs,
227
+ runId,
228
+ error: facts.error === null ? undefined : facts.error,
229
+ effect,
230
+ }));
231
+ }
232
+
233
+ _cacheWriteTotal(facts) {
234
+ const total = (facts.cacheWrite5mTokens || 0) + (facts.cacheWrite1hTokens || 0);
235
+ return total || undefined;
236
+ }
237
+
238
+ /**
239
+ * Exact dollars for this call, with provenance.
240
+ *
241
+ * A cost the provider reported wins: it is evidence. Ours is arithmetic over the provider's own
242
+ * token breakdown against the pinned rate card, including the cache split — cached input is 10x
243
+ * cheaper and cache writes 1.25–2x dearer, so a bridge that ignores the split is not imprecise,
244
+ * it is wrong by multiples on exactly the cache-heavy traffic agents generate. An unpriced model
245
+ * yields null, never an estimate.
246
+ */
247
+ _cost(facts) {
248
+ if (facts.reportedCostUsd !== null && facts.reportedCostUsd !== undefined) {
249
+ return [facts.reportedCostUsd, this.core.pricing.SOURCE_PROVIDER];
250
+ }
251
+ if (!facts.model || !semconv.hasUsage(facts)) return [null, null];
252
+ const cost = this.core.pricing.costFromUsage(facts.model, semconv.usageDict(facts));
253
+ return cost === null ? [null, null] : [cost, this.core.pricing.SOURCE_USAGE];
254
+ }
255
+ }
256
+
257
+ module.exports = { Bridge, SEMCONV_TAG };