@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/LICENSE +201 -0
- package/NOTICE +38 -0
- package/README.md +414 -0
- package/ai.d.ts +84 -0
- package/index.d.ts +433 -0
- package/otel.d.ts +80 -0
- package/package.json +93 -0
- package/policy.d.ts +141 -0
- package/src/ai.cjs +334 -0
- package/src/ai.js +39 -0
- package/src/core.cjs +2411 -0
- package/src/health.cjs +172 -0
- package/src/index.cjs +53 -0
- package/src/index.js +151 -0
- package/src/otel/bridge.cjs +257 -0
- package/src/otel/classify.cjs +166 -0
- package/src/otel/index.cjs +84 -0
- package/src/otel/index.js +39 -0
- package/src/otel/semconv.cjs +650 -0
- package/src/policy/engine.cjs +368 -0
- package/src/policy/envelope.cjs +256 -0
- package/src/policy/index.cjs +224 -0
- package/src/policy/rules.cjs +442 -0
- package/src/pricing.cjs +188 -0
- package/src/provenance.cjs +304 -0
- package/src/redact.cjs +734 -0
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 };
|