@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/core.cjs
ADDED
|
@@ -0,0 +1,2411 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* The whole SDK, in CommonJS, on purpose.
|
|
4
|
+
*
|
|
5
|
+
* Node has two module systems and a package that supports both has to decide where the *state*
|
|
6
|
+
* lives. If the ESM entry and the CJS entry each carried their own copy of the client, an app that
|
|
7
|
+
* `import`ed us in one file and `require`d us in another would get two queues, two session ids and
|
|
8
|
+
* two flush handlers for one process — the "dual package hazard", and in a governance ledger it
|
|
9
|
+
* shows up as one service reporting as two.
|
|
10
|
+
*
|
|
11
|
+
* CJS is the format both loaders can reach: ESM can `import` a CJS file, and it goes through the
|
|
12
|
+
* *same* require cache, so `src/index.js` (ESM) and `src/index.cjs` (CJS) observe one instance.
|
|
13
|
+
* The reverse layout does not hold — `require()` of an ESM file is only supported from Node 22.12,
|
|
14
|
+
* and even then the ESM graph is evaluated separately. So the core is CJS and the ESM entry is a
|
|
15
|
+
* facade. `test/dual.test.mjs` asserts the single instance rather than trusting this comment.
|
|
16
|
+
*
|
|
17
|
+
* Nothing in this file may throw into the host application. Everything public goes through
|
|
18
|
+
* `guard()`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const crypto = require('node:crypto');
|
|
23
|
+
const { AsyncLocalStorage } = require('node:async_hooks');
|
|
24
|
+
const provenance = require('./provenance.cjs');
|
|
25
|
+
const redactor = require('./redact.cjs');
|
|
26
|
+
const pricing = require('./pricing.cjs');
|
|
27
|
+
const { HealthRollup } = require('./health.cjs');
|
|
28
|
+
|
|
29
|
+
const SDK_VERSION = '0.1.0';
|
|
30
|
+
const CONTRACT_VERSION = '1';
|
|
31
|
+
|
|
32
|
+
/** The attach-point discriminator. Mirrors `nexus/contract.py:PRODUCER`. */
|
|
33
|
+
const PRODUCER = 'sdk';
|
|
34
|
+
|
|
35
|
+
const EPISTEMIC_BEHAVIOR = 'behavior_trace';
|
|
36
|
+
const EPISTEMIC_RATIONALISATION = 'rationalisation';
|
|
37
|
+
const EPISTEMIC_NARRATIVE = 'interaction_narrative';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------------------------
|
|
40
|
+
// counters — the SDK's account of itself (Python `_counters.py`)
|
|
41
|
+
// ---------------------------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const counters = Object.create(null);
|
|
44
|
+
|
|
45
|
+
function incr(name, n) {
|
|
46
|
+
counters[name] = (counters[name] || 0) + (n === undefined ? 1 : n);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function snapshot() {
|
|
50
|
+
return Object.assign({}, counters);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------------------------
|
|
54
|
+
// safety — Python `_safety.py`
|
|
55
|
+
// ---------------------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run `fn`, and if it throws, swallow it and return `fallback`.
|
|
59
|
+
*
|
|
60
|
+
* The contained error is counted, not logged in a loop: a wrapper that fails once usually fails on
|
|
61
|
+
* every call, and a per-call `console.error` turns one telemetry bug into a log-volume incident.
|
|
62
|
+
*/
|
|
63
|
+
function guard(name, fn, fallback) {
|
|
64
|
+
try {
|
|
65
|
+
return fn();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
incr('hook_error');
|
|
68
|
+
incr('hook_error.' + name);
|
|
69
|
+
if (process.env.NEXUS_DEBUG === '1') {
|
|
70
|
+
try { process.stderr.write('[nexus] contained in ' + name + ': ' + err + '\n'); } catch (_) {}
|
|
71
|
+
}
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Async twin of `guard`. A rejected promise inside a capture path is still our bug, not theirs. */
|
|
77
|
+
async function guardAsync(name, fn, fallback) {
|
|
78
|
+
try {
|
|
79
|
+
return await fn();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
incr('hook_error');
|
|
82
|
+
incr('hook_error.' + name);
|
|
83
|
+
if (process.env.NEXUS_DEBUG === '1') {
|
|
84
|
+
try { process.stderr.write('[nexus] contained in ' + name + ': ' + err + '\n'); } catch (_) {}
|
|
85
|
+
}
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a guarded failure hands back. Absorbs property access, calls, and `for await` so that host
|
|
92
|
+
* code written against a `Run` keeps running when the `Run` could not be built. Returning
|
|
93
|
+
* `undefined` instead would put our failure in the customer's stack trace two lines later as
|
|
94
|
+
* "cannot read property 'outcome' of undefined", which is exactly what `guard` exists to prevent.
|
|
95
|
+
*/
|
|
96
|
+
const INERT = new Proxy(function () {}, {
|
|
97
|
+
get(_t, prop) {
|
|
98
|
+
if (prop === Symbol.toPrimitive) return () => '';
|
|
99
|
+
if (prop === 'then') return undefined; // never look like a thenable to `await`
|
|
100
|
+
return INERT;
|
|
101
|
+
},
|
|
102
|
+
apply() { return INERT; },
|
|
103
|
+
construct() { return INERT; },
|
|
104
|
+
has() { return true; },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------------------------
|
|
108
|
+
// config
|
|
109
|
+
// ---------------------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
// Defined in `redact.cjs` and imported, rather than declared here and duplicated there. The
|
|
112
|
+
// redactor is the thing that acts on a tier, so it owns the vocabulary; two copies of three string
|
|
113
|
+
// literals is exactly the kind of duplication that survives a rename in one file only.
|
|
114
|
+
const { TIER_METADATA_ONLY, TIER_HASHED, TIER_FULL } = redactor;
|
|
115
|
+
|
|
116
|
+
function envEnabled() {
|
|
117
|
+
const v = process.env.NEXUS_ENABLED;
|
|
118
|
+
if (v === undefined) return true;
|
|
119
|
+
return ['0', 'false', 'no', 'off'].indexOf(String(v).trim().toLowerCase()) === -1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function envInt(name, fallback, min) {
|
|
123
|
+
const raw = process.env[name];
|
|
124
|
+
if (raw === undefined || String(raw).trim() === '') return fallback;
|
|
125
|
+
const n = Number(String(raw).trim());
|
|
126
|
+
if (!Number.isFinite(n)) return fallback;
|
|
127
|
+
return Math.max(min === undefined ? 1 : min, Math.floor(n));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The service name, from the first variable that names one.
|
|
132
|
+
*
|
|
133
|
+
* `NEXUS_SERVICE` first, then the names other observability stacks already set. A service running
|
|
134
|
+
* under OpenTelemetry, Datadog, Cloud Run or Lambda has *already* been told what it is called, and
|
|
135
|
+
* making the operator repeat it in a fifth variable is how a fleet ends up half-labelled `unknown`
|
|
136
|
+
* — which is worse than no label, because it aggregates every unlabelled service into one bucket
|
|
137
|
+
* that looks like a real one.
|
|
138
|
+
*
|
|
139
|
+
* The order is Python's, and the order is a precedence claim: a variable set specifically for this
|
|
140
|
+
* SDK outranks one set for a different tool that we are borrowing.
|
|
141
|
+
*/
|
|
142
|
+
function resolveServiceName() {
|
|
143
|
+
for (const v of ['NEXUS_SERVICE', 'OTEL_SERVICE_NAME', 'DD_SERVICE', 'K_SERVICE',
|
|
144
|
+
'AWS_LAMBDA_FUNCTION_NAME']) {
|
|
145
|
+
const value = (process.env[v] || '').trim();
|
|
146
|
+
if (value) return value;
|
|
147
|
+
}
|
|
148
|
+
// `null`, not `'unknown'`, so the caller can still fall through to the config file. Returning the
|
|
149
|
+
// default here would make the file rung unreachable for the one field most likely to be in it.
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The lowest configuration rung: a JSON file, read only when `NEXUS_CONFIG_FILE` names one.
|
|
155
|
+
*
|
|
156
|
+
* There is deliberately no `~/.nexus` and no implicit search path. A telemetry SDK that reads a
|
|
157
|
+
* file nobody pointed it at is a telemetry SDK whose behaviour depends on a machine's history, and
|
|
158
|
+
* the first time that matters is when a container behaves differently from a laptop for a reason
|
|
159
|
+
* neither the code nor the environment explains.
|
|
160
|
+
*
|
|
161
|
+
* A failing read is a warning and never an exception: this runs on the application's startup path,
|
|
162
|
+
* and a malformed JSON file must not stop a process from booting. The counter is what makes the
|
|
163
|
+
* failure visible without making it fatal.
|
|
164
|
+
*/
|
|
165
|
+
function loadConfigFile() {
|
|
166
|
+
const path = (process.env.NEXUS_CONFIG_FILE || '').trim();
|
|
167
|
+
if (!path) return {};
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
170
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
171
|
+
incr('config_file_invalid');
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
return parsed;
|
|
175
|
+
} catch (_err) {
|
|
176
|
+
incr('config_file_unreadable');
|
|
177
|
+
if (process.env.NEXUS_DEBUG === '1') {
|
|
178
|
+
try { process.stderr.write('[nexus] could not read NEXUS_CONFIG_FILE ' + path + '\n'); } catch (_) {}
|
|
179
|
+
}
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Which *instance* of the SDK this is — the value that rides as `terminal_id`.
|
|
186
|
+
*
|
|
187
|
+
* `hostname-pid` identifies a process, and for a long time that was enough. It is not enough under
|
|
188
|
+
* `worker_threads`: a `Worker` gets its own module registry and its own heap, so the SDK inside it
|
|
189
|
+
* is a genuinely separate instance with its own client, queue and session id — but it shares the
|
|
190
|
+
* process, and therefore shared a `terminal_id` with the main thread and with every sibling worker.
|
|
191
|
+
* Measured, not assumed: `fixtures/app/worker-app.mjs` produced two sessions whose `terminal_id`
|
|
192
|
+
* and `pid` were byte-identical, leaving nothing on the wire able to tell them apart.
|
|
193
|
+
*
|
|
194
|
+
* The thread id is appended rather than added as a new field. `terminal_id` is declared in
|
|
195
|
+
* `contract/events.v1.json` and its job is precisely "which emitting instance"; inventing a
|
|
196
|
+
* `thread_id` beside it would be adding an undeclared field to a type two producers write into,
|
|
197
|
+
* which is the drift this repository has already fixed once. Changing the *value* of a declared
|
|
198
|
+
* string field is a different act from growing the schema.
|
|
199
|
+
*
|
|
200
|
+
* `threadId` is `0` on the main thread, and that case keeps the old format exactly — so nothing
|
|
201
|
+
* changes for the overwhelmingly common deployment, and the suffix appears only where it carries
|
|
202
|
+
* information.
|
|
203
|
+
*/
|
|
204
|
+
function buildInstanceId() {
|
|
205
|
+
const base = (require('node:os').hostname() || 'host').slice(0, 32) + '-' + process.pid;
|
|
206
|
+
try {
|
|
207
|
+
const { threadId } = require('node:worker_threads');
|
|
208
|
+
return threadId ? base + '-t' + threadId : base;
|
|
209
|
+
} catch (_err) {
|
|
210
|
+
// `worker_threads` has been available since Node 12, so this is unreachable on any supported
|
|
211
|
+
// version. Guarded anyway because this runs in the constructor of the client, and a throw here
|
|
212
|
+
// would be a telemetry SDK preventing a process from starting.
|
|
213
|
+
return base;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Free-form tags, bounded.
|
|
219
|
+
*
|
|
220
|
+
* `tags` is a caller-supplied object that `base()` spreads onto **every event this SDK emits**, and
|
|
221
|
+
* it was previously taken exactly as given: `tags: o.tags || null`. That is the same shape as the
|
|
222
|
+
* `tool_action.effect` hole — an arbitrary object reaching the wire without passing the treatment
|
|
223
|
+
* its siblings pass through — and it was found by going looking for the shape rather than for the
|
|
224
|
+
* bug.
|
|
225
|
+
*
|
|
226
|
+
* The failure here is cardinality rather than disclosure, which is why it reads as harmless and is
|
|
227
|
+
* not. A customer looping a user id or a request id into `tags` multiplies the rollup's key space
|
|
228
|
+
* on *our* side of the wire, where it is expensive and where nobody can see it happening. Python
|
|
229
|
+
* bounds it — 32 entries, keys to 64 characters, values to 128, everything coerced to a string —
|
|
230
|
+
* and Node now bounds it identically, so a tag set does not mean one thing in one language and
|
|
231
|
+
* something unbounded in the other.
|
|
232
|
+
*
|
|
233
|
+
* Values are stringified rather than dropped: a tag whose value is a number is an ordinary thing
|
|
234
|
+
* for somebody to write, and refusing it would be a surprise where truncating is not. A non-object
|
|
235
|
+
* yields no tags at all rather than throwing, because this runs inside `init()`.
|
|
236
|
+
*/
|
|
237
|
+
function boundTags(raw) {
|
|
238
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
239
|
+
const out = {};
|
|
240
|
+
for (const k of Object.keys(raw).slice(0, 32)) {
|
|
241
|
+
const v = raw[k];
|
|
242
|
+
if (v === null || v === undefined) continue;
|
|
243
|
+
out[String(k).slice(0, 64)] = String(v).slice(0, 128);
|
|
244
|
+
}
|
|
245
|
+
return Object.keys(out).length ? out : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The Python SDK's default, and it has to be the same one or one collector needs two configs. */
|
|
249
|
+
const DEFAULT_COLLECTOR_URL = 'http://127.0.0.1:8791';
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Where events are POSTed.
|
|
253
|
+
*
|
|
254
|
+
* Three layers, in the same order the Python SDK resolves them:
|
|
255
|
+
*
|
|
256
|
+
* 1. an explicit `collectorUrl` / `NEXUS_COLLECTOR_URL` — a full URL, wins outright;
|
|
257
|
+
* 2. **sidecar mode** — `NEXUS_COLLECTOR_HOST` / `_PORT` / `_SCHEME`, composed into a URL. This
|
|
258
|
+
* exists because the default is loopback and a Kubernetes sidecar is not: the collector is a
|
|
259
|
+
* second container reachable by name, and hardcoding `127.0.0.1` is what makes an otherwise
|
|
260
|
+
* correct deployment silently capture nothing;
|
|
261
|
+
* 3. the loopback default.
|
|
262
|
+
*
|
|
263
|
+
* `'none'` is honoured at every layer as an explicit "no collector" — the file sink alone, which
|
|
264
|
+
* is what the test suite and offline debugging want. It is a sentinel rather than an empty string
|
|
265
|
+
* because an empty environment variable is far more often an accident than an instruction.
|
|
266
|
+
*
|
|
267
|
+
* A bare IPv6 host is bracketed, since `http://::1:8791` is not a parseable URL. Anything that
|
|
268
|
+
* still fails to parse falls back to the default rather than throwing: this runs inside `init()`,
|
|
269
|
+
* on the application's startup path, and a malformed variable must not stop a process from booting.
|
|
270
|
+
*/
|
|
271
|
+
function resolveCollectorUrl(o) {
|
|
272
|
+
const explicit = o.collectorUrl || process.env.NEXUS_COLLECTOR_URL;
|
|
273
|
+
if (explicit) return String(explicit).trim();
|
|
274
|
+
|
|
275
|
+
const host = (process.env.NEXUS_COLLECTOR_HOST || '').trim();
|
|
276
|
+
if (host) {
|
|
277
|
+
if (host.toLowerCase() === 'none') return 'none';
|
|
278
|
+
const scheme = (process.env.NEXUS_COLLECTOR_SCHEME || 'http').trim().toLowerCase();
|
|
279
|
+
const safeScheme = scheme === 'https' ? 'https' : 'http';
|
|
280
|
+
const port = envInt('NEXUS_COLLECTOR_PORT', 8791, 1);
|
|
281
|
+
const bracketed = host.includes(':') && !host.startsWith('[') ? '[' + host + ']' : host;
|
|
282
|
+
const composed = safeScheme + '://' + bracketed + ':' + port;
|
|
283
|
+
try {
|
|
284
|
+
// Round-trip it. A host variable carrying a path, a query or userinfo is a misconfiguration
|
|
285
|
+
// that would otherwise become a request to somewhere nobody intended.
|
|
286
|
+
const u = new URL(composed);
|
|
287
|
+
if (u.pathname === '/' && !u.search && !u.username && !u.password) return composed;
|
|
288
|
+
} catch (_) { /* fall through to the default */ }
|
|
289
|
+
incr('collector_host_invalid');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return DEFAULT_COLLECTOR_URL;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve the effective configuration.
|
|
297
|
+
*
|
|
298
|
+
* Precedence mirrors the Python SDK: explicit argument, then environment, then default — with the
|
|
299
|
+
* one documented exception that `NEXUS_ENABLED=0` beats everything, including
|
|
300
|
+
* `init({ enabled: true })`. An operator switching telemetry off from outside the process has to
|
|
301
|
+
* be able to beat application code that says otherwise, or it is not a kill switch.
|
|
302
|
+
*
|
|
303
|
+
* `service` / `env` / `version` fall back to a literal `'unknown'` / `null` because they are
|
|
304
|
+
* *labels* — an unlabelled service is a real state the ledger has to be able to represent.
|
|
305
|
+
* `application` / `repo` / `commit` do **not** get that treatment: they are *provenance*, and the
|
|
306
|
+
* whole point of the operate plane is that a missing link is drawn as a break rather than filled
|
|
307
|
+
* in. See `provenance.cjs`.
|
|
308
|
+
*/
|
|
309
|
+
function resolveConfig(opts) {
|
|
310
|
+
const o = opts || {};
|
|
311
|
+
// The lowest rung, below the environment. Read once per `init()` rather than cached, so a
|
|
312
|
+
// notebook that edits the file and calls `init()` again sees the change — the same reason
|
|
313
|
+
// `init()` is idempotent rather than first-call-wins.
|
|
314
|
+
const fileCfg = loadConfigFile();
|
|
315
|
+
/** Argument, then environment, then file, then default. */
|
|
316
|
+
const pick = (argValue, envName, fileKey, fallback) => {
|
|
317
|
+
if (argValue !== undefined && argValue !== null) return argValue;
|
|
318
|
+
const env = process.env[envName];
|
|
319
|
+
if (env !== undefined && String(env).trim() !== '') return String(env).trim();
|
|
320
|
+
if (fileCfg[fileKey] !== undefined && fileCfg[fileKey] !== null) return fileCfg[fileKey];
|
|
321
|
+
return fallback;
|
|
322
|
+
};
|
|
323
|
+
// The explicit layer, exactly as Python's `config.build` assembles it: the argument, then the
|
|
324
|
+
// `NEXUS_*` variable, then nothing. Anything that survives this is stamped `explicit`, because an
|
|
325
|
+
// operator who exported `NEXUS_COMMIT` told us as deliberately as a caller who passed it.
|
|
326
|
+
// `env` and `version` are handed in the same way rather than being overwritten by a platform
|
|
327
|
+
// variable afterwards — they have an established config home and a legacy variable each.
|
|
328
|
+
const prov = provenance.detect({
|
|
329
|
+
application: o.application || process.env.NEXUS_APPLICATION,
|
|
330
|
+
repo: o.repo || process.env.NEXUS_REPO,
|
|
331
|
+
commit: o.commit || process.env.NEXUS_COMMIT,
|
|
332
|
+
branch: o.branch || process.env.NEXUS_BRANCH,
|
|
333
|
+
deployment_id: o.deploymentId || process.env.NEXUS_DEPLOYMENT_ID,
|
|
334
|
+
env: o.env || process.env.NEXUS_ENV,
|
|
335
|
+
// `NEXUS_VERSION` first, because that is the name the Python SDK reads and the two have to
|
|
336
|
+
// agree or one estate needs two variables to say one thing. `NEXUS_SERVICE_VERSION` stays an
|
|
337
|
+
// accepted alias: it is what this package read while it was a spike, and quietly dropping it
|
|
338
|
+
// would turn a working configuration into an `unknown` label with no error anywhere.
|
|
339
|
+
version: o.version || process.env.NEXUS_VERSION || process.env.NEXUS_SERVICE_VERSION,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
// ── the three dimensions that ride EVERY event, and were the ones still unbounded ────────
|
|
344
|
+
//
|
|
345
|
+
// §7a of PARITY.md claimed this SDK had closed the dimension hole. It had not: `service`,
|
|
346
|
+
// `env` and `service_version` are on the base envelope of every event, `service` is the field
|
|
347
|
+
// the dashboard identifies an SDK-instrumented service BY, and all three were unbounded here
|
|
348
|
+
// while the Python SDK bounded all three (`config.py`: `[:128]`, `[:64]`, `[:64]`). So the
|
|
349
|
+
// widest-blast-radius dimensions of the whole set were the ones the sweep missed, in the
|
|
350
|
+
// direction opposite to the one §7a described. Found by the cross-SDK conformance suite
|
|
351
|
+
// rather than by re-reading either file.
|
|
352
|
+
//
|
|
353
|
+
// Bounds are Python's, exactly: a bound that differs by SDK is a cardinality difference a
|
|
354
|
+
// customer can see, which is the defect this is fixing rather than a smaller version of it.
|
|
355
|
+
// `'unknown'` is applied AFTER the bound, so a whitespace-only service name still lands on the
|
|
356
|
+
// sentinel rather than on an empty string.
|
|
357
|
+
service: label(o.service || resolveServiceName() || fileCfg.service, 128) || 'unknown',
|
|
358
|
+
env: label(prov.values.env, 64) || 'unknown',
|
|
359
|
+
version: label(prov.values.version, 64),
|
|
360
|
+
tier: pick(o.tier, 'NEXUS_TIER', 'tier', TIER_METADATA_ONLY),
|
|
361
|
+
|
|
362
|
+
// Egress. `sink` is a file path (the spike's original transport, and still the cheapest way
|
|
363
|
+
// to assert on a whole event corpus in a test); `collectorUrl` is `POST <url>` with an NDJSON
|
|
364
|
+
// body. Both may be set at once — someone debugging a silent pipeline wants the file *and*
|
|
365
|
+
// the collector.
|
|
366
|
+
sink: pick(o.sink, 'NEXUS_SDK_SINK', 'sink', null),
|
|
367
|
+
collectorUrl: resolveCollectorUrl(o),
|
|
368
|
+
apiKey: pick(o.apiKey, 'NEXUS_API_KEY', 'api_key', null),
|
|
369
|
+
|
|
370
|
+
// Operate-plane identity. Absent stays absent — never `'unknown'`.
|
|
371
|
+
//
|
|
372
|
+
// Bounded here rather than at each builder, because these five ride the *base envelope* and so
|
|
373
|
+
// appear on every event this SDK emits. That makes them the widest-blast-radius dimensions in
|
|
374
|
+
// the product: an unbounded `commit` is not one oversized field, it is one oversized field
|
|
375
|
+
// multiplied by every row. Bounding at the source also means a builder cannot forget.
|
|
376
|
+
application: label(prov.values.application, 128),
|
|
377
|
+
repo: label(prov.values.repo, 200),
|
|
378
|
+
commit: label(prov.values.commit, 128),
|
|
379
|
+
branch: label(prov.values.branch, 128),
|
|
380
|
+
deploymentId: label(prov.values.deployment_id, 128),
|
|
381
|
+
/** Per-field `{ commit: 'env:VERCEL_GIT_COMMIT_SHA', … }`. Never a key without a value. */
|
|
382
|
+
provenance: Object.keys(prov.sources).length ? prov.sources : null,
|
|
383
|
+
provenanceSource: provenance.primarySource(prov.sources),
|
|
384
|
+
provenanceIncomplete: prov.incomplete.length ? prov.incomplete : null,
|
|
385
|
+
|
|
386
|
+
tags: boundTags(o.tags || fileCfg.tags),
|
|
387
|
+
// `NEXUS_ENABLED=0` wins over `init({ enabled: true })`. The reverse would make the
|
|
388
|
+
// environment variable advisory, which is not what a kill switch is.
|
|
389
|
+
enabled: envEnabled() && (o.enabled === undefined ? true : !!o.enabled),
|
|
390
|
+
|
|
391
|
+
queueCapacity: o.queueCapacity || envInt('NEXUS_QUEUE_CAPACITY', 10000, 1),
|
|
392
|
+
batchSize: o.batchSize || envInt('NEXUS_BATCH_SIZE', 500, 1),
|
|
393
|
+
flushIntervalMs: o.flushIntervalMs || envInt('NEXUS_FLUSH_INTERVAL_MS', 2000, 10),
|
|
394
|
+
flushDeadlineMs: o.flushDeadlineMs || envInt('NEXUS_FLUSH_DEADLINE_MS', 2000, 10),
|
|
395
|
+
httpTimeoutMs: o.httpTimeoutMs || envInt('NEXUS_HTTP_TIMEOUT_MS', 2000, 10),
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* How often the `service_health` window closes. Python's `NEXUS_HEALTH_INTERVAL_S`, in the
|
|
399
|
+
* milliseconds every other duration in this SDK uses. `0` disables the rollup entirely.
|
|
400
|
+
*/
|
|
401
|
+
healthIntervalMs: o.healthIntervalMs === undefined
|
|
402
|
+
? envInt('NEXUS_HEALTH_INTERVAL_MS', 60000, 0)
|
|
403
|
+
: o.healthIntervalMs,
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Durable overflow for batches the collector permanently refused. Off by default, and the
|
|
407
|
+
* default is the interesting part: containers are frequently read-only, and a telemetry SDK
|
|
408
|
+
* that fails to start because it could not create a directory has inverted its own priorities.
|
|
409
|
+
* When it is off, an abandoned batch is counted and dropped — which is the honest outcome, and
|
|
410
|
+
* which `counters()` reports rather than hiding.
|
|
411
|
+
*/
|
|
412
|
+
spillDir: pick(o.spillDir, 'NEXUS_SPILL_DIR', 'spill_dir', null),
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Vercel's `waitUntil`, if the host handed us one. Without it a serverless function is frozen
|
|
416
|
+
* the instant it returns, and a background flush does not run late — it does not run at all.
|
|
417
|
+
* See the `serverless` section.
|
|
418
|
+
*/
|
|
419
|
+
waitUntil: typeof o.waitUntil === 'function' ? o.waitUntil : null,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------------------------
|
|
424
|
+
// contract — field-for-field with nexus-sdk/src/nexus/contract.py
|
|
425
|
+
// ---------------------------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
function now() {
|
|
428
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function fingerprint(text) {
|
|
432
|
+
return crypto.createHash('sha256').update(String(text == null ? '' : text), 'utf8')
|
|
433
|
+
.digest('hex').slice(0, 12);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** ~4 chars/token. Good enough for rollups; the provider's own count wins when present. */
|
|
437
|
+
function estTokens(text) {
|
|
438
|
+
return Math.floor(((text || '').length + 3) / 4);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The tier ladder itself, in one place: what content — if any — this tier may carry.
|
|
443
|
+
*
|
|
444
|
+
* Returns canonical keys (`text` / `preview` and their flags); the two public wrappers rename them.
|
|
445
|
+
* **T0 returns an empty object**: not a blank string, not a mask, nothing — so the field is absent
|
|
446
|
+
* and a reader can tell "no content was captured" from "the content was empty".
|
|
447
|
+
*
|
|
448
|
+
* `full` is redacted too. A tier is a decision about *content*; it is never a waiver on
|
|
449
|
+
* credentials, and a `full` branch that sliced raw text is how `integration_probe.error` ships an
|
|
450
|
+
* unscrubbed vendor exception to anyone who set `tier: 'full'`.
|
|
451
|
+
*
|
|
452
|
+
* Both rungs go through `scanWindow`, so the bound and the unfinished-scan post-condition cannot
|
|
453
|
+
* hold in one caller and not the other.
|
|
454
|
+
*
|
|
455
|
+
* ── What this replaces, and why the old behaviour was worse than it looked ───────────────────
|
|
456
|
+
*
|
|
457
|
+
* This SDK used to answer the tier question with three lines: at `full` the text truncated, at
|
|
458
|
+
* `hashed` the string `sha256:<12 hex>`, at `metadata_only` nothing. Two defects in that, one in
|
|
459
|
+
* each direction:
|
|
460
|
+
*
|
|
461
|
+
* - `full` did not redact. The Python SDK's `full` scrubs credentials and PII on the way out, so
|
|
462
|
+
* the same configuration produced scrubbed text there and unscrubbed text here, into the same
|
|
463
|
+
* field of the same ledger.
|
|
464
|
+
* - `hashed` was a digest where Python's is a redacted preview. Stricter, but the same word meaning
|
|
465
|
+
* two things is its own defect: a reader joining the two populations gets opaque hashes from half
|
|
466
|
+
* their estate and readable text from the other half, under one tier name.
|
|
467
|
+
*/
|
|
468
|
+
function rungs(text, tier, fullLimit, previewLimit) {
|
|
469
|
+
if (tier === TIER_FULL) {
|
|
470
|
+
const r = redactor.scanWindow(text, fullLimit);
|
|
471
|
+
const out = { text: r.unfinished ? redactor.MASK : r.text, text_redacted: r.redacted || r.unfinished };
|
|
472
|
+
if (fullLimit !== null && fullLimit !== undefined) out.text_truncated = text.length > fullLimit;
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
if (tier === TIER_HASHED) {
|
|
476
|
+
const r = redactor.scanWindow(text, previewLimit);
|
|
477
|
+
return {
|
|
478
|
+
preview: r.unfinished ? redactor.MASK : r.text,
|
|
479
|
+
preview_redacted: r.redacted || r.unfinished,
|
|
480
|
+
preview_truncated: text.length > previewLimit,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// `metadata_only`, and anything unrecognised. A new tier or a typo degrades to silence rather
|
|
484
|
+
// than to egress, which is the only failure direction worth having here.
|
|
485
|
+
return {};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* A *named* free-text field, gated by tier, as a fragment to spread into `base()`.
|
|
490
|
+
*
|
|
491
|
+
* Shape survives every tier. `<name>_chars` and `<name>_fingerprint` are content-free, so at T0
|
|
492
|
+
* "the same error as yesterday" stays answerable, deploy actors stay countable and a repeated
|
|
493
|
+
* target stays groupable, without the text leaving the process. The content key is `<name>` at T2
|
|
494
|
+
* and `<name>_preview` at T1; at T0 it does not exist.
|
|
495
|
+
*
|
|
496
|
+
* Empty in, empty out — an absent field writes nothing, not a zero length.
|
|
497
|
+
*/
|
|
498
|
+
function tieredText(name, text, tier, fullLimit, previewLimit) {
|
|
499
|
+
const txt = (typeof text === 'string' ? text : (text ? String(text) : '')).trim();
|
|
500
|
+
if (!txt) return {};
|
|
501
|
+
const out = {};
|
|
502
|
+
out[name + '_chars'] = txt.length;
|
|
503
|
+
out[name + '_fingerprint'] = fingerprint(txt);
|
|
504
|
+
const r = rungs(txt, tier, fullLimit === undefined ? 512 : fullLimit,
|
|
505
|
+
previewLimit === undefined ? 280 : previewLimit);
|
|
506
|
+
for (const k of Object.keys(r)) {
|
|
507
|
+
// `text` is the field itself; `text_redacted` is a flag *about* the field and reads as
|
|
508
|
+
// `<name>_redacted`, matching `<name>_preview_redacted` one rung down. Leaving the canonical
|
|
509
|
+
// name in would spell it `actor_text_redacted`, inventing a second word for one thing.
|
|
510
|
+
const suffix = k.startsWith('text_') ? k.slice(5) : (k === 'text' ? '' : k);
|
|
511
|
+
out[suffix ? name + '_' + suffix : name] = r[k];
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Tier-appropriate rendering of a free-text field with *unprefixed* keys, for the model-text events
|
|
518
|
+
* that carry exactly one such field and rename the fragment on the way in.
|
|
519
|
+
*
|
|
520
|
+
* Same ladder as {@link tieredText}, so a tier cannot mean one thing here and another there.
|
|
521
|
+
*/
|
|
522
|
+
function redactPreview(text, tier, limit) {
|
|
523
|
+
if (!text) return null;
|
|
524
|
+
const s = String(text);
|
|
525
|
+
const frag = { chars: s.length, fingerprint: fingerprint(s), tokens_est: estTokens(s) };
|
|
526
|
+
Object.assign(frag, rungs(s, tier, null, limit === undefined ? 280 : limit));
|
|
527
|
+
return frag;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The last-resort gate for a caller that only wants a string back.
|
|
532
|
+
*
|
|
533
|
+
* Retained because a handful of internal call sites want exactly one value rather than a fragment,
|
|
534
|
+
* and because removing it would push the tier decision back out to those call sites — which is
|
|
535
|
+
* precisely how these fields leaked in the wrapper, one path redacted and three not. Prefer
|
|
536
|
+
* {@link tieredText}: shape at T0 is strictly more useful than nothing.
|
|
537
|
+
*/
|
|
538
|
+
function wireText(text, tier, max) {
|
|
539
|
+
if (text === null || text === undefined) return null;
|
|
540
|
+
const s = String(text);
|
|
541
|
+
if (!s) return s;
|
|
542
|
+
if (tier !== TIER_FULL && tier !== TIER_HASHED) return null;
|
|
543
|
+
const r = redactor.scanWindow(s, max || 256);
|
|
544
|
+
return r.unfinished ? redactor.MASK : r.text;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* A **dimension**: a low-cardinality label a consumer groups by. Bounded in length, never redacted.
|
|
549
|
+
*
|
|
550
|
+
* These are the fields a rollup does `GROUP BY` on — `goal_class`, `model`, `provider`,
|
|
551
|
+
* `error_class`, `kind`. They are not content, so the tier ladder is the wrong instrument; what
|
|
552
|
+
* they need is a bound, and they had none. That is the same class as the `tags` finding: a
|
|
553
|
+
* cardinality failure rather than a disclosure one, and cardinality failures are invisible in
|
|
554
|
+
* exactly the way disclosure failures are not — nothing looks wrong, the rollup just quietly
|
|
555
|
+
* degrades on our side of the wire.
|
|
556
|
+
*
|
|
557
|
+
* **What this does and does not fix, stated because the difference matters.** Truncating to a
|
|
558
|
+
* bound stops a megabyte of prose becoming a group-by key; it does not reduce the *number of
|
|
559
|
+
* distinct values*, which is the other half of cardinality. Capping distinct values in-process
|
|
560
|
+
* would mean remembering every value seen, which is itself unbounded memory, so it is not
|
|
561
|
+
* attempted here and belongs to the collector. Bounding the length is the tractable half and it is
|
|
562
|
+
* the half that turns a pathological payload into a merely wrong label.
|
|
563
|
+
*
|
|
564
|
+
* The Python SDK bounds `agent_run.name`, `tool_action.tool_name`/`action`, `turn_outcome.outcome`
|
|
565
|
+
* and `verified_by`, and leaves the rest of these unbounded — so this is a gap the two SDKs share
|
|
566
|
+
* rather than a Node regression, and worth closing on both sides.
|
|
567
|
+
*/
|
|
568
|
+
function label(v, max) {
|
|
569
|
+
if (v === null || v === undefined) return null;
|
|
570
|
+
const s = String(v).trim();
|
|
571
|
+
return s ? s.slice(0, max || 64) : null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function dropNulls(obj) {
|
|
575
|
+
const out = {};
|
|
576
|
+
for (const k of Object.keys(obj)) {
|
|
577
|
+
if (obj[k] !== null && obj[k] !== undefined) out[k] = obj[k];
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The common envelope. `producer` and `epistemic_class` are written here and nowhere else, so
|
|
584
|
+
* "never emit an unclassified event" is a property of one function rather than of every call site.
|
|
585
|
+
*/
|
|
586
|
+
function base(type, sessionId, cfg, epistemicClass, extra) {
|
|
587
|
+
const e = {
|
|
588
|
+
schema: CONTRACT_VERSION,
|
|
589
|
+
event_id: crypto.randomUUID().replace(/-/g, ''),
|
|
590
|
+
type,
|
|
591
|
+
session_id: sessionId,
|
|
592
|
+
ts: now(),
|
|
593
|
+
producer: PRODUCER,
|
|
594
|
+
epistemic_class: epistemicClass,
|
|
595
|
+
service: cfg.service,
|
|
596
|
+
env: cfg.env,
|
|
597
|
+
service_version: cfg.version,
|
|
598
|
+
sdk_version: SDK_VERSION,
|
|
599
|
+
|
|
600
|
+
// ── operate-plane join keys ──────────────────────────────────────────────────────────────
|
|
601
|
+
//
|
|
602
|
+
// On every event rather than on `session` alone, for the same reason `service`/`env` already
|
|
603
|
+
// are (`contract.py:base`): events from one process outlive the session record that
|
|
604
|
+
// introduced them, and a consumer that has to look up a session to know which application a
|
|
605
|
+
// row belongs to will eventually be handed a row whose session it never received.
|
|
606
|
+
//
|
|
607
|
+
// These are the fields that turn `repo ──► commit ──► ??? ──► version ──► running service`
|
|
608
|
+
// into a closed chain. Every one of them may be absent, and `dropNulls` below means absent is
|
|
609
|
+
// literally absent — the console draws the break, and drawing the break is the product.
|
|
610
|
+
application: cfg.application,
|
|
611
|
+
repo: cfg.repo,
|
|
612
|
+
commit: cfg.commit,
|
|
613
|
+
/** Which variable the commit was read from, e.g. `env:VERCEL_GIT_COMMIT_SHA`. */
|
|
614
|
+
provenance_source: cfg.provenanceSource,
|
|
615
|
+
};
|
|
616
|
+
Object.assign(e, dropNulls(extra || {}));
|
|
617
|
+
if (cfg.tags) e.tags = cfg.tags;
|
|
618
|
+
return dropNulls(e);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const contract = {
|
|
622
|
+
session(sessionId, cfg, instanceId, runtime) {
|
|
623
|
+
const e = base('session', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
624
|
+
// ── `provenance` is the VALUES, with their sources nested — the Python SDK's shape ───────
|
|
625
|
+
//
|
|
626
|
+
// This used to be the per-field *sources* map (`{commit: 'env:VERCEL_GIT_COMMIT_SHA'}`)
|
|
627
|
+
// while the Python SDK's `provenance` on the same event type is the *values*
|
|
628
|
+
// (`{commit: '9f2c1ab…'}`) with `source` and `sources` nested inside it. One wire key, two
|
|
629
|
+
// meanings, split by which SDK the customer happened to install: a reader doing
|
|
630
|
+
// `session.provenance.commit` got a SHA from one half of the estate and the literal string
|
|
631
|
+
// `"explicit"` from the other. Exactly the `session.repo` collision below, one level in, and
|
|
632
|
+
// found the same way — by diffing the two emitted streams rather than by reading either file.
|
|
633
|
+
//
|
|
634
|
+
// Python's shape wins because "provenance" naming the values is what every other consumer
|
|
635
|
+
// already assumes, and because the sources are still there, one level down, losing nothing.
|
|
636
|
+
provenance: (() => {
|
|
637
|
+
const v = dropNulls({
|
|
638
|
+
application: cfg.application, repo: cfg.repo, commit: cfg.commit,
|
|
639
|
+
branch: cfg.branch, deployment_id: cfg.deploymentId,
|
|
640
|
+
source: cfg.provenanceSource, sources: cfg.provenance,
|
|
641
|
+
});
|
|
642
|
+
return Object.keys(v).length ? v : null;
|
|
643
|
+
})(),
|
|
644
|
+
// The counterpart nobody thinks to ship, and Node-only: it names the fields a platform
|
|
645
|
+
// *nearly* answered. Without it, "Vercel set the repo slug but not the owner, so we
|
|
646
|
+
// published no repo" is indistinguishable from "no platform variables at all", and the
|
|
647
|
+
// customer whose ribbon has a break has no way to find out which one they are.
|
|
648
|
+
provenance_incomplete: cfg.provenanceIncomplete,
|
|
649
|
+
branch: cfg.branch,
|
|
650
|
+
platform_deployment_id: cfg.deploymentId,
|
|
651
|
+
terminal_id: instanceId, tool: 'sdk', privacy_tier: cfg.tier,
|
|
652
|
+
// ── no `redactor` key, and its removal is the fix rather than a feature being dropped ────
|
|
653
|
+
//
|
|
654
|
+
// This event used to carry `redactor: 'none'`, declared so that a collector could tell apart
|
|
655
|
+
// two populations writing `privacy_tier: "full"` while meaning different things by it — the
|
|
656
|
+
// Python SDK scrubbing credentials on the way out and this one not. Since PARITY.md §3a that
|
|
657
|
+
// is no longer true: `src/redact.cjs` is a port of `redact.py`, verified differentially at
|
|
658
|
+
// 178 cases. So the field was asserting, on every session row in the ledger, something that
|
|
659
|
+
// had stopped being the case — and it is exactly the field an operator would have trusted to
|
|
660
|
+
// decide whether text from a Node service was safe.
|
|
661
|
+
//
|
|
662
|
+
// It is also a type the contract has no model for: `session` declares no `redactor` in
|
|
663
|
+
// `contract/events.v1.json`, so nothing could ever have validated it. Undeclared, untrue,
|
|
664
|
+
// and Python-less: removed rather than corrected, which is the `data_expectation` lesson.
|
|
665
|
+
runtime, pid: process.pid,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// ── a field-name collision between two producers, resolved in our favour by moving ────────
|
|
669
|
+
//
|
|
670
|
+
// `base()` puts the provenance repo slug (`github.com/acme/portal`) on every event as a
|
|
671
|
+
// string. On `session` that lands on top of a key the OTHER producer already owns and means
|
|
672
|
+
// something else by: `nexus-devtools/events.py:session` takes `repo: Optional[dict]` — a local
|
|
673
|
+
// repository descriptor keeping an absolute `root` — and `contract/events.v1.json` declares
|
|
674
|
+
// `session.repo` as `{"type": "object"}` accordingly. Four other event types declare `repo` as
|
|
675
|
+
// a string; `session` is the only one that does not.
|
|
676
|
+
//
|
|
677
|
+
// So a `session` row's `repo` was an object from `wrap` and a string from this SDK. Nothing
|
|
678
|
+
// rejects it — the collector validates schema major, not per-field types — so the damage would
|
|
679
|
+
// have been a reader doing `session.repo.root` and getting `undefined` for exactly the rows
|
|
680
|
+
// that came from production, which is the half of the ledger it most wanted.
|
|
681
|
+
//
|
|
682
|
+
// Renamed rather than dropped: the slug is a real operate-plane join key and `session` is
|
|
683
|
+
// where a reader looks for it. `repo_slug` is additive, unambiguous, and cannot be mistaken
|
|
684
|
+
// for the other producer's field by a reader or by a schema.
|
|
685
|
+
if (e.repo !== undefined) {
|
|
686
|
+
e.repo_slug = e.repo;
|
|
687
|
+
delete e.repo;
|
|
688
|
+
}
|
|
689
|
+
return e;
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
agentRun(sessionId, cfg, o) {
|
|
693
|
+
return base('agent_run', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
694
|
+
run_id: o.runId, name: String(o.name).slice(0, 128), phase: o.phase,
|
|
695
|
+
goal_class: label(o.goalClass), duration_ms: o.durationMs, actions: o.actions,
|
|
696
|
+
// The tiered fragment, matching `nexus/contract.py`'s limits field for field. At T0 this
|
|
697
|
+
// contributes `error_chars` and `error_fingerprint` and no text — which is strictly more
|
|
698
|
+
// than the old gate gave, because "the same error as yesterday" stays answerable without
|
|
699
|
+
// the message leaving the process.
|
|
700
|
+
...tieredText('error', o.error, cfg.tier, 256, 256),
|
|
701
|
+
incomplete: o.incomplete || null,
|
|
702
|
+
});
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
toolAction(sessionId, cfg, o) {
|
|
706
|
+
return base('tool_action', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
707
|
+
tool_name: String(o.toolName).slice(0, 64),
|
|
708
|
+
action: String(o.action || 'invoke').slice(0, 64),
|
|
709
|
+
// `target_fingerprint` is spelled by `tieredText` itself now. It used to be computed
|
|
710
|
+
// separately here, beside a `wireText` call that produced the content — two helpers
|
|
711
|
+
// deciding one field, which is how the shape drifted from the Python builder's.
|
|
712
|
+
...tieredText('target', o.target, cfg.tier, 256, 256),
|
|
713
|
+
blocked: !!o.blocked,
|
|
714
|
+
...tieredText('reason', o.reason, cfg.tier, 256, 256),
|
|
715
|
+
duration_ms: o.durationMs,
|
|
716
|
+
caused_by_prompt_id: o.runId,
|
|
717
|
+
// ── `effect` is customer data, and it used to reach the wire with no gate at all ─────────
|
|
718
|
+
//
|
|
719
|
+
// Every other content-bearing field on this event goes through the tier ladder. This one was
|
|
720
|
+
// spread verbatim: `effect: o.effect || null`. So `act.effect({ headers: { Authorization:
|
|
721
|
+
// 'Bearer …' } })` — or `{ ssn: 123456789 }`, or a tool's whole response object — shipped
|
|
722
|
+
// unredacted at `metadata_only`, the tier whose entire promise is that no content leaves the
|
|
723
|
+
// process.
|
|
724
|
+
//
|
|
725
|
+
// It was invisible for the same reason the Python file's own comment gives: a redactor that
|
|
726
|
+
// only walks free text passes a credential straight through when it arrives as a structured
|
|
727
|
+
// value. Objects are not strings, so no string gate ever saw them.
|
|
728
|
+
effect: o.effect ? redactor.scrubMapping(o.effect, cfg.tier) : null,
|
|
729
|
+
...tieredText('error', o.error, cfg.tier, 256, 256),
|
|
730
|
+
});
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
tokenUsage(sessionId, cfg, o) {
|
|
734
|
+
// ── cost ────────────────────────────────────────────────────────────────────────────────
|
|
735
|
+
//
|
|
736
|
+
// A caller-supplied figure wins and is stamped `provider`, because a number the provider
|
|
737
|
+
// itself reported outranks one we derived. Otherwise it is computed from the token breakdown
|
|
738
|
+
// and stamped `usage`. Provenance is part of the record: an inference has to be legible as one
|
|
739
|
+
// beside a reported figure, or a reconciliation against an invoice cannot tell them apart.
|
|
740
|
+
//
|
|
741
|
+
// An unpriced model yields `null` for both, and the envelope drops null keys — so a missing
|
|
742
|
+
// cost is an absent field rather than a zero. A gap somebody can fill; an invented cost is a
|
|
743
|
+
// gap nobody can see.
|
|
744
|
+
//
|
|
745
|
+
// Where this differs from Python: there, `usage()` takes `cost_usd` as a parameter and the
|
|
746
|
+
// computation lives in the OTel bridge and the streams integration. Node has neither yet, so
|
|
747
|
+
// computing here is what makes the field reachable at all — same rate card, same function,
|
|
748
|
+
// called one layer down. Noted in PARITY.md rather than left to be discovered.
|
|
749
|
+
let costUsd = o.costUsd;
|
|
750
|
+
let costSource = o.costSource;
|
|
751
|
+
if (costUsd === undefined || costUsd === null) {
|
|
752
|
+
costUsd = o.model ? pricing.costFromTokens(o.model, o) : null;
|
|
753
|
+
costSource = costUsd === null || costUsd === undefined ? null : pricing.SOURCE_USAGE;
|
|
754
|
+
} else if (!costSource) {
|
|
755
|
+
costSource = pricing.SOURCE_PROVIDER;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return base('token_usage', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
759
|
+
model: label(o.model, 128), provider: label(o.provider),
|
|
760
|
+
input_tokens: o.inputTokens | 0, output_tokens: o.outputTokens | 0,
|
|
761
|
+
cache_read_tokens: o.cacheReadTokens, cache_write_tokens: o.cacheWriteTokens,
|
|
762
|
+
cost_usd: costUsd, cost_source: label(costSource, 32),
|
|
763
|
+
caused_by_prompt_id: o.runId,
|
|
764
|
+
attempts: o.attempts && o.attempts !== 1 ? o.attempts : null,
|
|
765
|
+
incomplete: o.incomplete || null,
|
|
766
|
+
instrumentation: label(o.instrumentation, 32),
|
|
767
|
+
});
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
turnOutcome(sessionId, cfg, o) {
|
|
771
|
+
let verified = o.verified;
|
|
772
|
+
if ((verified === undefined || verified === null) && o.verifiedBy) verified = true;
|
|
773
|
+
return base('turn_outcome', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
774
|
+
prompt_id: o.runId, outcome: String(o.outcome).slice(0, 64),
|
|
775
|
+
verified: verified === undefined ? null : verified,
|
|
776
|
+
verified_by: o.verifiedBy ? String(o.verifiedBy).slice(0, 64) : null,
|
|
777
|
+
actions: o.actions || 0, blocked: o.blocked || 0,
|
|
778
|
+
partial: o.partial || null,
|
|
779
|
+
});
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The SDK's report on itself. Three field-level corrections, all found by the cross-SDK
|
|
784
|
+
* conformance suite and all of them cross-producer name collisions rather than cosmetics:
|
|
785
|
+
*
|
|
786
|
+
* - **`queue_depth`, not `pending`.** `pending` is a declared field on this type and it means
|
|
787
|
+
* something else: `nexus_devtools/events.py:pipeline_health` documents it as events sitting
|
|
788
|
+
* past the drain watermark — a *ledger backlog* on disk. This SDK has no ledger; it has an
|
|
789
|
+
* in-memory queue. Writing an in-memory depth into the backlog column gives one column two
|
|
790
|
+
* meanings across two producers, which is the `session.repo` mistake with a different name.
|
|
791
|
+
* The Python SDK already spelled it `queue_depth`; this one now agrees.
|
|
792
|
+
* - **No `nexus_version`.** Same shape. `nexus_version` is the `wrap` CLI's version. This
|
|
793
|
+
* SDK's version is `sdk_version`, which `base()` already writes on every event, so the field
|
|
794
|
+
* was both wrong and redundant.
|
|
795
|
+
* - **Counters spread flat, not nested under `counters`.** PARITY.md §5 has claimed since it
|
|
796
|
+
* was written that they are spread flat "as in Python". They were not — they were an object,
|
|
797
|
+
* so a consumer reading `events_sent` off a Python row read `undefined` off a Node one. A
|
|
798
|
+
* documented claim that no test checked, which is the class of defect this suite exists for.
|
|
799
|
+
*/
|
|
800
|
+
pipelineHealth(sessionId, cfg, o) {
|
|
801
|
+
return base('pipeline_health', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
802
|
+
terminal_id: o.instanceId, checkpoint: o.checkpoint,
|
|
803
|
+
queue_depth: o.queueDepth, collector_up: !!o.collectorUp,
|
|
804
|
+
// Restated on every health record, not just at session start: the `session` event is written
|
|
805
|
+
// before the app's first provider import, so at that point the honest answer is always
|
|
806
|
+
// "none". The value only becomes true once something has actually been hooked.
|
|
807
|
+
instrumentation: describeInstrumentation(),
|
|
808
|
+
runtime: o.runtime || null,
|
|
809
|
+
...Object.fromEntries(Object.entries(o.counters || {}).map(([k, v]) => [k, Number(v) | 0])),
|
|
810
|
+
});
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
814
|
+
// operate plane — events 28 and 30, plus the declaration that makes 30's alarm honest.
|
|
815
|
+
//
|
|
816
|
+
// Shapes are copied field for field from `nexus-web-app/lib/features/operate/types.ts`, which
|
|
817
|
+
// is the console's read contract. Snake_case on the wire, `detected_by` on every record,
|
|
818
|
+
// `epistemic_class` from `base()`. These types are not yet in `contract/events.v1.json` — see
|
|
819
|
+
// ANCHOR-INTEGRATION §5.2 and SCOPE.md §7 D4; `test/contract.test.mjs` pins the drift so that
|
|
820
|
+
// any *further* undeclared type fails on the day it is introduced.
|
|
821
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Event 28 — a version reached an environment, self-reported by the process running it.
|
|
825
|
+
*
|
|
826
|
+
* `detected_by: 'self'` is not a formality. The console labels it as the weakest claim on the
|
|
827
|
+
* plane, because a process asserting its own deployment is exactly the evidence a shadow deploy
|
|
828
|
+
* would also produce. A CI or cloud connector record beats this one wherever both exist; this
|
|
829
|
+
* exists so that an estate with no connectors still has *something* to draw, and so that the
|
|
830
|
+
* something is honestly marked.
|
|
831
|
+
*/
|
|
832
|
+
deployment(sessionId, cfg, o) {
|
|
833
|
+
return base('deployment', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
834
|
+
deployment_id: o.deploymentId,
|
|
835
|
+
app_id: label(o.appId, 128),
|
|
836
|
+
env: label(o.env),
|
|
837
|
+
version: label(o.version, 128),
|
|
838
|
+
commit: label(o.commit, 128),
|
|
839
|
+
repo: label(o.repo, 200),
|
|
840
|
+
// Free text on an operate event, gated like every other. `nexus-devtools` keeps `actor` at
|
|
841
|
+
// all three tiers because a deployment ledger that cannot say who did it is not a ledger,
|
|
842
|
+
// and relies on its redactor to scrub the commit-author email a forge connector usually
|
|
843
|
+
// supplies. This SDK now has that redactor, so the rule is the same one rather than a
|
|
844
|
+
// conservative approximation of it: `actor_fingerprint` makes deploy actors countable at
|
|
845
|
+
// T0, and the name itself appears at T1 and T2 with the email scrubbed out of it.
|
|
846
|
+
...tieredText('actor', o.actor, cfg.tier, 128, 128),
|
|
847
|
+
started_ts: o.startedTs,
|
|
848
|
+
finished_ts: o.finishedTs,
|
|
849
|
+
outcome: label(o.outcome, 32),
|
|
850
|
+
rollback_of: label(o.rollbackOf, 128),
|
|
851
|
+
detected_by: 'self',
|
|
852
|
+
// No `provenance` here. It was the per-field *sources* map, `contract/events.v1.json`
|
|
853
|
+
// declares no such field on `deployment`, and the Python SDK deliberately keeps provenance
|
|
854
|
+
// on `session` and nowhere else — "emitted once per process identity event" is the whole
|
|
855
|
+
// argument for nesting it rather than repeating it. A second producer writing an undeclared
|
|
856
|
+
// field onto a shared operate-plane type is the `data_expectation` shape again.
|
|
857
|
+
});
|
|
858
|
+
},
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Event 30 — one observation of an outbound dependency.
|
|
862
|
+
*
|
|
863
|
+
* **The two registers must never be merged.** `reachable` says the call completed; `rows` and
|
|
864
|
+
* `last_data_ts` say what came back. "The call succeeded" and "the call succeeded and returned
|
|
865
|
+
* zero rows" are different facts, and "the newest row we saw is three days old" is a third.
|
|
866
|
+
* A single green dot cannot express the PRD's own headline case — API healthy, data stale —
|
|
867
|
+
* which is why every competitor's integration page cannot express it either.
|
|
868
|
+
*
|
|
869
|
+
* Consequently, and this is the part that is easy to undo by accident: a key that was not
|
|
870
|
+
* supplied is absent, not `false` and not `0`. `rows: 0` means *we counted, and there were
|
|
871
|
+
* none*; no `rows` key means *we did not count*. `base()` drops nulls, so the only way to
|
|
872
|
+
* preserve the difference is to never invent a default here.
|
|
873
|
+
*/
|
|
874
|
+
integrationProbe(sessionId, cfg, o) {
|
|
875
|
+
const e = base('integration_probe', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
876
|
+
integration: String(o.integration).slice(0, 128),
|
|
877
|
+
app_id: label(o.appId, 128),
|
|
878
|
+
service: label(o.service, 128),
|
|
879
|
+
kind: label(o.kind),
|
|
880
|
+
observed_ts: o.observedTs,
|
|
881
|
+
|
|
882
|
+
// ── LIVENESS ────────────────────────────────────────────────────────────────────────────
|
|
883
|
+
// `integration_up`, not `reachable`, because that is the name the producer contract uses
|
|
884
|
+
// (`nexus_devtools/events.py:integration_probe`, from which `contract/events.v1.json` is
|
|
885
|
+
// generated). The console's `lib/features/operate/types.ts` still spells it `reachable`; it
|
|
886
|
+
// was written before the builders landed. Two names for one register is how the distinction
|
|
887
|
+
// this event exists to make gets quietly merged, so the producer's name wins here.
|
|
888
|
+
integration_up: o.integrationUp,
|
|
889
|
+
auth_ok: o.authOk,
|
|
890
|
+
latency_ms: o.latencyMs,
|
|
891
|
+
|
|
892
|
+
// ── FRESHNESS — a different register, and never derived from the one above ──────────────
|
|
893
|
+
last_data_ts: o.lastDataTs,
|
|
894
|
+
rows: o.rows,
|
|
895
|
+
schema_fingerprint: label(o.schemaFingerprint, 64),
|
|
896
|
+
|
|
897
|
+
// Content-free companion to `error`. It survives every tier, which matters because the
|
|
898
|
+
// alarm branches on it: an alarm that only works at `full` is not an alarm.
|
|
899
|
+
error_class: label(o.errorClass),
|
|
900
|
+
detected_by: 'sdk',
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// Error text on the full ladder, with the limits `nexus/contract.py` uses for this field
|
|
904
|
+
// (512 / 280 — wider than the others, because a vendor exception is where the diagnosis is).
|
|
905
|
+
//
|
|
906
|
+
// The `hashed` rung used to be missing here: shape at every tier, text at `full`, and nothing
|
|
907
|
+
// in between, "because previewing safely needs a redactor this SDK does not have". It has one
|
|
908
|
+
// now, so the rung exists and this field stops being the one place the ladder had a step
|
|
909
|
+
// missing. That gap was also the most consequential place to have it — an integration probe's
|
|
910
|
+
// error is the single field an operator reads first when something has gone wrong.
|
|
911
|
+
Object.assign(e, tieredText('error', o.error, cfg.tier, 512, 280));
|
|
912
|
+
return e;
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* A window of observed service behaviour — a behaviour trace.
|
|
917
|
+
*
|
|
918
|
+
* Every quantity is independently optional, and that is the whole design of the event. A producer
|
|
919
|
+
* that measured latency but not saturation must not be made to look like one that measured
|
|
920
|
+
* neither, and a zero written where nothing was measured is the specific lie this plane exists to
|
|
921
|
+
* avoid: a service with `errors` absent is unmeasured, a service with `errors: 0` is healthy, and
|
|
922
|
+
* one number cannot be allowed to mean both. `base()` drops nulls, so absent stays absent all the
|
|
923
|
+
* way to the wire.
|
|
924
|
+
*
|
|
925
|
+
* The window is two explicit timestamps rather than a duration, because the reader's question is
|
|
926
|
+
* "over which period" and a bare `"5m"` cannot answer it once the record arrives late.
|
|
927
|
+
*/
|
|
928
|
+
serviceHealth(sessionId, cfg, o) {
|
|
929
|
+
return base('service_health', sessionId, cfg, EPISTEMIC_BEHAVIOR, {
|
|
930
|
+
service: label(o.service, 128), app_id: label(o.appId, 128), env: label(o.env),
|
|
931
|
+
window_from: o.windowFrom, window_to: o.windowTo,
|
|
932
|
+
requests: o.requests, errors: o.errors,
|
|
933
|
+
p50_ms: o.p50Ms, p95_ms: o.p95Ms, p99_ms: o.p99Ms,
|
|
934
|
+
saturation: o.saturation,
|
|
935
|
+
detected_by: 'sdk',
|
|
936
|
+
});
|
|
937
|
+
},
|
|
938
|
+
|
|
939
|
+
// ── There is deliberately no `dataExpectation` builder here ─────────────────────────────────
|
|
940
|
+
//
|
|
941
|
+
// One used to sit at this spot and emit a `data_expectation` event. It was removed, and the
|
|
942
|
+
// removal is the parity fix rather than a feature being dropped.
|
|
943
|
+
//
|
|
944
|
+
// That type exists nowhere else in the system: `nexus_devtools/events.py` has no builder for it,
|
|
945
|
+
// so it is absent from the 51 `$defs` of `contract/events.v1.json`, which is generated from those
|
|
946
|
+
// builders. The Python SDK reaches the same point and stops — `nexus/api.py` keeps its `DECLARED`
|
|
947
|
+
// map in-process under a TODO stating that the declaration has no wire representation and that
|
|
948
|
+
// inventing an event type ahead of the artifact is forbidden.
|
|
949
|
+
//
|
|
950
|
+
// This SDK invented it anyway: a second producer writing a type the ledger has no model for,
|
|
951
|
+
// which is the drift the contract test exists to catch and which it missed because its corpus
|
|
952
|
+
// omitted the operate app. See `expectsData`, which still records the declaration in-process.
|
|
953
|
+
//
|
|
954
|
+
// Whether the event *should* exist is a decision for whoever owns the devtools event builders.
|
|
955
|
+
// Neither SDK settles it by emitting and letting the schema be regenerated to match.
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
// ---------------------------------------------------------------------------------------------
|
|
959
|
+
// context — Python `context.py`. `AsyncLocalStorage` is Node's `contextvars`.
|
|
960
|
+
// ---------------------------------------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
const als = new AsyncLocalStorage();
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Two mechanisms, deliberately.
|
|
966
|
+
*
|
|
967
|
+
* `AsyncLocalStorage.run()` is the correct primitive — it propagates across `await`, `Promise.all`
|
|
968
|
+
* and timers, and it cannot leak one request's run into another's. But it requires the caller to
|
|
969
|
+
* hand us a callback, and `nexus.agent()` in this API returns a `Run` the caller holds across
|
|
970
|
+
* arbitrary code, exactly like Python's context manager. There is no `enterWith`-free way to make
|
|
971
|
+
* the returned-object form propagate, so `_stack` is the fallback for that shape and `runInScope`
|
|
972
|
+
* is offered for callers who can pass a callback and want real async isolation.
|
|
973
|
+
*
|
|
974
|
+
* This asymmetry is a genuine Node-vs-Python cost and it is written up in SCOPE.md §3.
|
|
975
|
+
*/
|
|
976
|
+
const _stack = [];
|
|
977
|
+
|
|
978
|
+
function currentRun() {
|
|
979
|
+
const fromAls = als.getStore();
|
|
980
|
+
if (fromAls) return fromAls;
|
|
981
|
+
return _stack.length ? _stack[_stack.length - 1] : null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ---------------------------------------------------------------------------------------------
|
|
985
|
+
// transport
|
|
986
|
+
//
|
|
987
|
+
// Two rules govern everything below.
|
|
988
|
+
//
|
|
989
|
+
// **The calling thread never performs I/O.** `enqueue` is an array push and a counter increment;
|
|
990
|
+
// nothing else happens on the path a customer's request is on. Draining is a timer's job, and the
|
|
991
|
+
// drain itself is async — `fs.promises.appendFile`, or an HTTP request. The one exception is the
|
|
992
|
+
// `exit` handler, which Node only lets run synchronous work, and which by definition is not on
|
|
993
|
+
// anybody's request path. That exception is why `flushSyncLastResort` exists and why it is named
|
|
994
|
+
// the way it is: so that nobody reaches for it because it looked convenient.
|
|
995
|
+
//
|
|
996
|
+
// **The queue is bounded and drops OLDEST.** A telemetry buffer that grows without limit turns
|
|
997
|
+
// our fault into the host's out-of-memory incident; a bounded one that refuses new events keeps
|
|
998
|
+
// the *least* interesting data, because the newest events are the ones describing the incident
|
|
999
|
+
// that caused the backlog. So the head goes. Every drop is counted, and the count is public —
|
|
1000
|
+
// silence about dropped data is a bug, not a tidy default.
|
|
1001
|
+
// ---------------------------------------------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
/** `true` for `http://…` / `https://…`; anything else is treated as a file path. */
|
|
1004
|
+
function isHttp(target) {
|
|
1005
|
+
return typeof target === 'string' && /^https?:\/\//i.test(target);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Serialise a batch one event at a time — and that is the point.
|
|
1010
|
+
*
|
|
1011
|
+
* A customer can hand us a circular object in `effect({ … })`, or a getter that throws, or a
|
|
1012
|
+
* `BigInt`. Serialising the batch in one `map` means one poisonous event destroys the other four
|
|
1013
|
+
* hundred beside it, and the loss is silent because the exception surfaces as a failed flush
|
|
1014
|
+
* rather than as a bad payload. Per-event, the poison costs exactly itself; the drop is counted
|
|
1015
|
+
* under its own name so it can be told apart from a transport failure, which has a different fix.
|
|
1016
|
+
*
|
|
1017
|
+
* Returns the per-event strings rather than a finished body, because the two sinks want different
|
|
1018
|
+
* envelopes around the same events — see {@link ndjsonBody} and {@link collectorBody}.
|
|
1019
|
+
*
|
|
1020
|
+
* @returns {string[]} one JSON string per event that could be serialised; may be empty.
|
|
1021
|
+
*/
|
|
1022
|
+
function serialise(batch) {
|
|
1023
|
+
const lines = [];
|
|
1024
|
+
for (const e of batch) {
|
|
1025
|
+
try {
|
|
1026
|
+
lines.push(JSON.stringify(e));
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
// Counted once per event, not once per attempt. A process configured with both sinks
|
|
1029
|
+
// serialises the same batch twice, and a poison payload that reported two drops for one lost
|
|
1030
|
+
// event would make the counters — the only place this loss is visible at all — wrong in the
|
|
1031
|
+
// direction that matters, by overstating it.
|
|
1032
|
+
if (!e[UNSERIALISABLE]) {
|
|
1033
|
+
try { Object.defineProperty(e, UNSERIALISABLE, { value: true }); } catch (_) {}
|
|
1034
|
+
incr('build_failed');
|
|
1035
|
+
incr('queue_dropped');
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return lines;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Per-event bookkeeping that must never reach the wire.
|
|
1044
|
+
*
|
|
1045
|
+
* Symbols rather than properties: `JSON.stringify` ignores symbol keys entirely, so there is no
|
|
1046
|
+
* way for one of these to escape into an event body even if a future refactor stops filtering
|
|
1047
|
+
* them, and no way for either to collide with a field the contract grows later.
|
|
1048
|
+
*/
|
|
1049
|
+
const WRITTEN_TO_FILE = Symbol('nexus.writtenToFile');
|
|
1050
|
+
const UNSERIALISABLE = Symbol('nexus.unserialisable');
|
|
1051
|
+
|
|
1052
|
+
/** The file sink's format. NDJSON, because a file is appended to and read back line by line. */
|
|
1053
|
+
function ndjsonBody(lines) {
|
|
1054
|
+
return lines.length ? lines.join('\n') + '\n' : null;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* The collector's format, and it is NOT NDJSON.
|
|
1059
|
+
*
|
|
1060
|
+
* `POST /v1/events` in `nexus-devtools/collector.py` reads the body with a single `json.loads` and
|
|
1061
|
+
* then looks for an `events` list:
|
|
1062
|
+
*
|
|
1063
|
+
* data = json.loads(raw) if raw else {}
|
|
1064
|
+
* if isinstance(data, dict) and "events" in data: events = data.get("events")
|
|
1065
|
+
* else: events = [data]
|
|
1066
|
+
*
|
|
1067
|
+
* An NDJSON body of two or more events is not valid JSON, so it fails at `json.loads` and comes
|
|
1068
|
+
* back `400 {"error": "invalid json"}`. The spike sent NDJSON to the bare collector URL with no
|
|
1069
|
+
* `/v1/events` path, which means it earned a 404 first and would have earned a 400 after — both
|
|
1070
|
+
* classified as permanent by `postJson` below, both therefore counted and dropped rather than
|
|
1071
|
+
* retried. The customer-visible symptom is an empty dashboard and a healthy application, which is
|
|
1072
|
+
* the precise failure mode this SDK exists to make impossible. `test/transport.test.mjs` runs the
|
|
1073
|
+
* real ingest contract against a real server rather than trusting this comment.
|
|
1074
|
+
*
|
|
1075
|
+
* The array is assembled from pre-serialised strings so that poison isolation above survives: one
|
|
1076
|
+
* unserialisable event is dropped and counted, and the other four hundred still ship.
|
|
1077
|
+
*/
|
|
1078
|
+
function collectorBody(lines) {
|
|
1079
|
+
return lines.length ? '{"events":[' + lines.join(',') + ']}' : null;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* The ingest path, appended to whatever base URL the operator configured.
|
|
1084
|
+
*
|
|
1085
|
+
* Mirrors `Config.events_url` in the Python SDK (`collector_url.rstrip("/") + "/v1/events"`) so
|
|
1086
|
+
* that one `NEXUS_COLLECTOR_URL` value works for both SDKs. Idempotent: a base that already ends
|
|
1087
|
+
* in the path is left alone, because an operator who copied the full endpoint out of the docs
|
|
1088
|
+
* should not silently get `/v1/events/v1/events`.
|
|
1089
|
+
*/
|
|
1090
|
+
function eventsUrl(base) {
|
|
1091
|
+
const trimmed = String(base).replace(/\/+$/, '');
|
|
1092
|
+
return /\/v1\/events$/.test(trimmed) ? trimmed : trimmed + '/v1/events';
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
class Sink {
|
|
1096
|
+
constructor(cfg) {
|
|
1097
|
+
this.cfg = cfg;
|
|
1098
|
+
this.queue = [];
|
|
1099
|
+
this.path = cfg.sink && cfg.sink !== 'none' ? cfg.sink : null;
|
|
1100
|
+
this.url = cfg.collectorUrl && cfg.collectorUrl !== 'none' ? cfg.collectorUrl : null;
|
|
1101
|
+
this.lastSendOk = null;
|
|
1102
|
+
this._inflight = null;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Accept one event. Synchronous, allocation-only, and never throws.
|
|
1107
|
+
*
|
|
1108
|
+
* @returns {boolean} whether the event was accepted. `false` means the queue was full *and*
|
|
1109
|
+
* something older was discarded to make room — the event itself is always kept.
|
|
1110
|
+
*/
|
|
1111
|
+
enqueue(event) {
|
|
1112
|
+
if (!event) return false;
|
|
1113
|
+
const cap = this.cfg.queueCapacity || 10000;
|
|
1114
|
+
let dropped = 0;
|
|
1115
|
+
while (this.queue.length >= cap) {
|
|
1116
|
+
this.queue.shift();
|
|
1117
|
+
dropped += 1;
|
|
1118
|
+
}
|
|
1119
|
+
if (dropped) {
|
|
1120
|
+
incr('queue_dropped', dropped);
|
|
1121
|
+
// Peak depth is the number that tells a customer whether they are near the edge or over it.
|
|
1122
|
+
if ((counters.queue_depth_peak || 0) < cap) counters.queue_depth_peak = cap;
|
|
1123
|
+
}
|
|
1124
|
+
this.queue.push(event);
|
|
1125
|
+
incr('events_enqueued');
|
|
1126
|
+
if (this.queue.length > (counters.queue_depth_peak || 0)) counters.queue_depth_peak = this.queue.length;
|
|
1127
|
+
return dropped === 0;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
depth() { return this.queue.length; }
|
|
1131
|
+
|
|
1132
|
+
/** Nothing configured means the events are counted and discarded — a supported test mode. */
|
|
1133
|
+
get inert() { return !this.path && !this.url; }
|
|
1134
|
+
|
|
1135
|
+
_take() {
|
|
1136
|
+
const batch = this.queue;
|
|
1137
|
+
this.queue = [];
|
|
1138
|
+
return batch;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
_requeueFront(batch) {
|
|
1142
|
+
// Put an undelivered batch back at the *front*, so ordering survives a transient failure, and
|
|
1143
|
+
// let `enqueue`'s cap shed the oldest if the backlog is now over the line. Re-queueing is only
|
|
1144
|
+
// done for errors that plausibly clear (a timeout, a refused connection). A 4xx does not
|
|
1145
|
+
// clear by being retried, and re-queueing it is how a queue becomes a memory leak.
|
|
1146
|
+
this.queue = batch.concat(this.queue);
|
|
1147
|
+
const cap = this.cfg.queueCapacity || 10000;
|
|
1148
|
+
if (this.queue.length > cap) {
|
|
1149
|
+
const over = this.queue.length - cap;
|
|
1150
|
+
this.queue.splice(0, over);
|
|
1151
|
+
incr('queue_dropped', over);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Drain the queue, off the calling thread, within `deadlineMs`.
|
|
1157
|
+
*
|
|
1158
|
+
* Bounded on purpose. An unbounded flush turns container shutdown into a hang: the collector is
|
|
1159
|
+
* exactly the thing most likely to be unhealthy at the moment the fleet is being restarted, and
|
|
1160
|
+
* a telemetry SDK that will not let a pod terminate has become the outage.
|
|
1161
|
+
*
|
|
1162
|
+
* @returns {Promise<boolean>} whether the queue emptied and the write was acknowledged.
|
|
1163
|
+
*/
|
|
1164
|
+
async flush(deadlineMs) {
|
|
1165
|
+
if (!this.queue.length) return true;
|
|
1166
|
+
if (this._inflight) {
|
|
1167
|
+
// Concurrent flushes (timer + explicit call) must not interleave two writers on one file.
|
|
1168
|
+
try { await this._inflight; } catch (_) { /* the other caller owns that outcome */ }
|
|
1169
|
+
if (!this.queue.length) return true;
|
|
1170
|
+
}
|
|
1171
|
+
const deadline = deadlineMs === undefined ? (this.cfg.flushDeadlineMs || 2000) : deadlineMs;
|
|
1172
|
+
const batch = this._take();
|
|
1173
|
+
const p = this._send(batch, deadline);
|
|
1174
|
+
this._inflight = p;
|
|
1175
|
+
try {
|
|
1176
|
+
return await p;
|
|
1177
|
+
} finally {
|
|
1178
|
+
if (this._inflight === p) this._inflight = null;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Optional durable overflow for a batch the collector permanently refused. Off unless `spillDir`
|
|
1184
|
+
* is configured.
|
|
1185
|
+
*
|
|
1186
|
+
* Off by default, and the default is the point: containers are frequently read-only, and a
|
|
1187
|
+
* telemetry SDK that fails because it could not create a directory has inverted its own
|
|
1188
|
+
* priorities. When it is off, an abandoned batch is counted and dropped, which `counters()`
|
|
1189
|
+
* reports rather than hides.
|
|
1190
|
+
*
|
|
1191
|
+
* Synchronous, and deliberately so: this runs on the abandon path, which is also reachable from
|
|
1192
|
+
* the exit handler, and an async write there is a write that does not happen. The cost is paid
|
|
1193
|
+
* only when a batch was going to be lost anyway.
|
|
1194
|
+
*
|
|
1195
|
+
* Nothing here can throw. A spill that fails is a spill that did not happen — the batch was
|
|
1196
|
+
* already abandoned, so the only thing a raised error could add is an outage.
|
|
1197
|
+
*/
|
|
1198
|
+
_spill(batch) {
|
|
1199
|
+
if (!this.cfg.spillDir) return;
|
|
1200
|
+
try {
|
|
1201
|
+
fs.mkdirSync(this.cfg.spillDir, { recursive: true });
|
|
1202
|
+
// The file name carries a timestamp and the pid, so two processes spilling in the same
|
|
1203
|
+
// second on a shared volume do not silently overwrite one another's evidence.
|
|
1204
|
+
const name = 'nexus-spill-' + Date.now() + '-' + process.pid + '.jsonl';
|
|
1205
|
+
const body = ndjsonBody(serialise(batch));
|
|
1206
|
+
if (body) fs.appendFileSync(require('node:path').join(this.cfg.spillDir, name), body);
|
|
1207
|
+
incr('spilled', batch.length);
|
|
1208
|
+
} catch (_err) {
|
|
1209
|
+
incr('spill_failed');
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
async _send(batch, deadlineMs) {
|
|
1214
|
+
// Before the inert check, so the rollup works with `NEXUS_COLLECTOR_URL=none` and in tests. It
|
|
1215
|
+
// reads events that are on their way out anyway; the calling path pays a bounded fold over a
|
|
1216
|
+
// batch it is already serialising.
|
|
1217
|
+
if (this.onDrain) this.onDrain(batch);
|
|
1218
|
+
if (this.inert) { this.lastSendOk = true; return true; }
|
|
1219
|
+
let ok = true;
|
|
1220
|
+
|
|
1221
|
+
// ── the file sink, over the events it has not already been given ──────────────────────────
|
|
1222
|
+
//
|
|
1223
|
+
// The filter is load-bearing rather than an optimisation. The two sinks share one queue but
|
|
1224
|
+
// have different retry semantics: an HTTP failure that is retryable puts the whole batch back
|
|
1225
|
+
// on the queue, and without this the next flush would append those same events to the file a
|
|
1226
|
+
// second time. That produces duplicate `event_id`s in the file — the one field the ledger
|
|
1227
|
+
// dedups on — so a single unreachable collector would silently corrupt the local corpus of a
|
|
1228
|
+
// process that was configured with both. Surfaced by `test/contract.test.mjs`'s duplicate-id
|
|
1229
|
+
// assertion the moment the collector default became a real URL rather than `null`.
|
|
1230
|
+
//
|
|
1231
|
+
// Marked with a Symbol so the flag is non-enumerable, never reaches `JSON.stringify`, and
|
|
1232
|
+
// cannot collide with a field the contract might grow later.
|
|
1233
|
+
if (this.path) {
|
|
1234
|
+
const fresh = batch.filter((e) => !e[WRITTEN_TO_FILE]);
|
|
1235
|
+
const lines = serialise(fresh);
|
|
1236
|
+
if (lines.length) {
|
|
1237
|
+
try {
|
|
1238
|
+
await withDeadline(fs.promises.appendFile(this.path, ndjsonBody(lines)), deadlineMs);
|
|
1239
|
+
for (const e of fresh) {
|
|
1240
|
+
try { Object.defineProperty(e, WRITTEN_TO_FILE, { value: true }); } catch (_) {}
|
|
1241
|
+
}
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
incr('disk_error');
|
|
1244
|
+
incr('queue_dropped', fresh.length);
|
|
1245
|
+
// Deliberately not re-queued: an unwritable path usually stays unwritable, and a queue
|
|
1246
|
+
// that only grows turns a telemetry fault into the host's memory problem.
|
|
1247
|
+
ok = false;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (this.url) {
|
|
1253
|
+
const lines = serialise(batch);
|
|
1254
|
+
if (!lines.length) { this.lastSendOk = ok; return ok; } // every event was unserialisable
|
|
1255
|
+
const res = await postJson(this.url, collectorBody(lines), this.cfg, deadlineMs);
|
|
1256
|
+
if (res.ok) {
|
|
1257
|
+
incr('send_ok');
|
|
1258
|
+
incr('events_sent', batch.length);
|
|
1259
|
+
} else {
|
|
1260
|
+
incr('send_failed');
|
|
1261
|
+
if (res.retryable) {
|
|
1262
|
+
this._requeueFront(batch);
|
|
1263
|
+
incr('requeued', batch.length);
|
|
1264
|
+
} else {
|
|
1265
|
+
// Permanent: a 400 from a body the collector cannot parse, a 404 from a wrong path, a
|
|
1266
|
+
// 401 from a credential it will not accept. Retrying any of those forever is how a
|
|
1267
|
+
// telemetry queue becomes a memory leak, so the batch is abandoned — and abandoned is
|
|
1268
|
+
// counted apart from evicted, because "the collector refused it" and "we ran out of
|
|
1269
|
+
// room" have completely different fixes.
|
|
1270
|
+
this._spill(batch);
|
|
1271
|
+
incr('send_abandoned', batch.length);
|
|
1272
|
+
incr('queue_dropped', batch.length);
|
|
1273
|
+
}
|
|
1274
|
+
ok = false;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
this.lastSendOk = ok;
|
|
1279
|
+
return ok;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* The `exit` handler's only option.
|
|
1284
|
+
*
|
|
1285
|
+
* Node runs `exit` listeners synchronously and does not wait for a promise, so this is the one
|
|
1286
|
+
* place a synchronous write is the honest choice rather than a lazy one. It writes to the file
|
|
1287
|
+
* sink only: there is no synchronous HTTP in Node, and pretending otherwise (a blocking
|
|
1288
|
+
* `execSync('curl')`, say) would be a worse hang than the data loss it prevents. When only a
|
|
1289
|
+
* collector URL is configured, an un-flushed buffer at `exit` is **lost**, it is counted as
|
|
1290
|
+
* lost, and `docs`/README say so rather than implying delivery.
|
|
1291
|
+
*/
|
|
1292
|
+
flushSyncLastResort() {
|
|
1293
|
+
if (!this.queue.length) return true;
|
|
1294
|
+
const batch = this._take();
|
|
1295
|
+
if (!this.path) {
|
|
1296
|
+
incr('queue_dropped', batch.length);
|
|
1297
|
+
incr('dropped_at_exit', batch.length);
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
try {
|
|
1301
|
+
const body = ndjsonBody(serialise(batch.filter((e) => !e[WRITTEN_TO_FILE])));
|
|
1302
|
+
if (body !== null) fs.appendFileSync(this.path, body);
|
|
1303
|
+
return true;
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
incr('disk_error');
|
|
1306
|
+
incr('queue_dropped', batch.length);
|
|
1307
|
+
return false;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** Reject with a marker error once `ms` has elapsed, without leaving a timer holding the loop. */
|
|
1313
|
+
function withDeadline(promise, ms) {
|
|
1314
|
+
return new Promise((resolve, reject) => {
|
|
1315
|
+
const t = setTimeout(() => reject(new Error('nexus: deadline')), ms);
|
|
1316
|
+
if (typeof t.unref === 'function') t.unref();
|
|
1317
|
+
promise.then(
|
|
1318
|
+
(v) => { clearTimeout(t); resolve(v); },
|
|
1319
|
+
(e) => { clearTimeout(t); reject(e); },
|
|
1320
|
+
);
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Whether the bearer credential may travel to this URL.
|
|
1326
|
+
*
|
|
1327
|
+
* Mirrors `HttpSink._auth_allowed` in the Python SDK, and it is a real defence rather than
|
|
1328
|
+
* ceremony. The default collector is loopback, so the ordinary case is safe; the dangerous case is
|
|
1329
|
+
* an operator who points `NEXUS_COLLECTOR_URL` at `http://collector.internal:8791` and does not
|
|
1330
|
+
* think about the fact that the token now crosses a network in cleartext, where anything on the
|
|
1331
|
+
* path can read it and replay it.
|
|
1332
|
+
*
|
|
1333
|
+
* The rule: send it over `https:` anywhere, or over `http:` only to a loopback host. Otherwise
|
|
1334
|
+
* WITHHOLD it — do not refuse to send the events. Withholding produces a 401 from the collector,
|
|
1335
|
+
* which latches and is visible to the operator as a diagnosable auth failure. Refusing the whole
|
|
1336
|
+
* send would produce silence, and silence is indistinguishable from an application that is simply
|
|
1337
|
+
* not busy.
|
|
1338
|
+
*
|
|
1339
|
+
* `0.0.0.0` is deliberately not loopback: it is a bind-any address, not a destination, and
|
|
1340
|
+
* treating it as local is how a token ends up on a wire.
|
|
1341
|
+
*/
|
|
1342
|
+
function authAllowed(u) {
|
|
1343
|
+
if (u.protocol === 'https:') return true;
|
|
1344
|
+
const host = (u.hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
1345
|
+
if (host === 'localhost' || host === '::1') return true;
|
|
1346
|
+
// 127.0.0.0/8, and only that. `127.1` is a legal spelling of loopback but not one anybody
|
|
1347
|
+
// configures deliberately, and a narrow test that occasionally withholds is the safe direction.
|
|
1348
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* `POST` one batch of events as JSON. Zero dependencies, so `node:http`/`node:https` directly.
|
|
1353
|
+
*
|
|
1354
|
+
* Never throws — the caller is a flush that must not become the customer's exception. The return
|
|
1355
|
+
* distinguishes *retryable* (timeout, connection refused, 5xx, 429) from *permanent* (4xx), which
|
|
1356
|
+
* is the difference between a queue that recovers and a queue that leaks.
|
|
1357
|
+
*/
|
|
1358
|
+
function postJson(url, body, cfg, deadlineMs) {
|
|
1359
|
+
return new Promise((resolve) => {
|
|
1360
|
+
let settled = false;
|
|
1361
|
+
const done = (r) => { if (!settled) { settled = true; resolve(r); } };
|
|
1362
|
+
try {
|
|
1363
|
+
const u = new URL(eventsUrl(url));
|
|
1364
|
+
const mod = u.protocol === 'https:' ? require('node:https') : require('node:http');
|
|
1365
|
+
const headers = {
|
|
1366
|
+
'content-type': 'application/json',
|
|
1367
|
+
'content-length': Buffer.byteLength(body),
|
|
1368
|
+
// The self-exclusion marker, spelled exactly as the Python SDK spells it. A nexus process
|
|
1369
|
+
// observing its own telemetry egress would report itself as a busy outbound integration
|
|
1370
|
+
// and, worse, feed its own health signal back into the ledger it is producing.
|
|
1371
|
+
'x-nexus-sdk-egress': '1',
|
|
1372
|
+
'user-agent': 'nexus-sdk-node/' + SDK_VERSION,
|
|
1373
|
+
'x-nexus-producer': PRODUCER,
|
|
1374
|
+
'x-nexus-sdk-version': SDK_VERSION,
|
|
1375
|
+
};
|
|
1376
|
+
if (cfg.apiKey && authAllowed(u)) headers.authorization = 'Bearer ' + cfg.apiKey;
|
|
1377
|
+
else if (cfg.apiKey) incr('auth_withheld');
|
|
1378
|
+
|
|
1379
|
+
const req = mod.request(u, {
|
|
1380
|
+
method: 'POST',
|
|
1381
|
+
headers,
|
|
1382
|
+
timeout: Math.min(cfg.httpTimeoutMs || 2000, deadlineMs),
|
|
1383
|
+
}, (res) => {
|
|
1384
|
+
// Drain, or the socket is never released back to the agent.
|
|
1385
|
+
res.resume();
|
|
1386
|
+
res.on('end', () => {
|
|
1387
|
+
const code = res.statusCode || 0;
|
|
1388
|
+
// The collector answers 202 with `{"accepted": n}`; 2xx generally means ingested.
|
|
1389
|
+
if (code >= 200 && code < 300) return done({ ok: true, retryable: false, status: code });
|
|
1390
|
+
// 3xx lands here as a permanent failure, and that is the intended behaviour rather than
|
|
1391
|
+
// an oversight. `node:http` does not follow redirects on its own, so unlike `fetch` there
|
|
1392
|
+
// is no risk of replaying the `Authorization` header to a host the operator never
|
|
1393
|
+
// configured — the Python SDK has to install a `_NoRedirect` handler to get the same
|
|
1394
|
+
// property. Treating it as permanent means a misconfigured redirect is loud (counted as
|
|
1395
|
+
// abandoned) instead of an infinite retry loop against a URL that will never accept us.
|
|
1396
|
+
if (code === 401 || code === 403) incr('auth_failed');
|
|
1397
|
+
done({ ok: false, retryable: code === 429 || code >= 500, status: code });
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
req.on('timeout', () => { req.destroy(); done({ ok: false, retryable: true, status: 0 }); });
|
|
1401
|
+
req.on('error', () => done({ ok: false, retryable: true, status: 0 }));
|
|
1402
|
+
req.end(body);
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
// A malformed URL is permanent; retrying it forever would be a busy loop against nothing.
|
|
1405
|
+
incr('disk_error');
|
|
1406
|
+
done({ ok: false, retryable: false, status: 0 });
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// ---------------------------------------------------------------------------------------------
|
|
1412
|
+
// client
|
|
1413
|
+
// ---------------------------------------------------------------------------------------------
|
|
1414
|
+
|
|
1415
|
+
let _client = null;
|
|
1416
|
+
|
|
1417
|
+
class Client {
|
|
1418
|
+
constructor(cfg) {
|
|
1419
|
+
this.cfg = cfg;
|
|
1420
|
+
this.sessionId = crypto.randomUUID().replace(/-/g, '');
|
|
1421
|
+
this.instanceId = buildInstanceId();
|
|
1422
|
+
this.sink = new Sink(cfg);
|
|
1423
|
+
this.started = false;
|
|
1424
|
+
this.health = new HealthRollup(now);
|
|
1425
|
+
// The rollup reads what the sink is already draining, so it has to be handed the batch on the
|
|
1426
|
+
// way past rather than asked for it. A callback rather than a back-reference: the sink has no
|
|
1427
|
+
// other reason to know a client exists, and giving it one would make the drain path depend on
|
|
1428
|
+
// a whole object graph it does not use.
|
|
1429
|
+
this.sink.onDrain = (batch) => { guard('health.observe', () => this.health.observe(batch)); };
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
start() {
|
|
1433
|
+
if (this.started) return;
|
|
1434
|
+
this.started = true;
|
|
1435
|
+
this.emit(contract.session(this.sessionId, this.cfg, this.instanceId, {
|
|
1436
|
+
node: process.version,
|
|
1437
|
+
// Which loader saw us. The single most useful field when a customer reports silence, because
|
|
1438
|
+
// "bundled" is a documented dead end for auto-instrumentation (F3 case 1.18) and this is how
|
|
1439
|
+
// support sees it without asking.
|
|
1440
|
+
instrumentation: describeInstrumentation(),
|
|
1441
|
+
}));
|
|
1442
|
+
this.emitHealth('session_start');
|
|
1443
|
+
this._startTimer();
|
|
1444
|
+
|
|
1445
|
+
// `exit` only permits synchronous work — Node does not await a promise returned from an `exit`
|
|
1446
|
+
// listener — which is why this is the one call site allowed to reach for the synchronous
|
|
1447
|
+
// write, and why that method is named `flushSyncLastResort` rather than something convenient.
|
|
1448
|
+
// A container being torn down does not wait, and an `exit` handler is by definition not on
|
|
1449
|
+
// anybody's request path.
|
|
1450
|
+
this._onExit = () => {
|
|
1451
|
+
guard('client.atexit', () => { this.emitHealth('stop'); this.sink.flushSyncLastResort(); });
|
|
1452
|
+
};
|
|
1453
|
+
// `beforeExit` still has a working event loop, so the real asynchronous flush can run here and
|
|
1454
|
+
// usually does — `exit` then finds an empty queue. This is the ordinary path for a CLI or a
|
|
1455
|
+
// test process; the synchronous one above is the backstop for `process.exit()` and signals.
|
|
1456
|
+
this._onBeforeExit = () => { guard('client.beforeExit', () => { void this.flush(); }); };
|
|
1457
|
+
process.once('exit', this._onExit);
|
|
1458
|
+
process.once('beforeExit', this._onBeforeExit);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* The background drain.
|
|
1463
|
+
*
|
|
1464
|
+
* `unref()` is the whole point: a telemetry timer that keeps the event loop alive turns a script
|
|
1465
|
+
* that should have exited into one that hangs for the flush interval, and the customer reads
|
|
1466
|
+
* that as our bug because it is. An unref'd timer runs while the process has other work and
|
|
1467
|
+
* stops holding it open the moment it does not.
|
|
1468
|
+
*/
|
|
1469
|
+
_startTimer() {
|
|
1470
|
+
if (this.sink.inert) return; // nothing configured: no timer, nothing to drain to
|
|
1471
|
+
this._timer = setInterval(() => {
|
|
1472
|
+
guard('client.tick', () => {
|
|
1473
|
+
// The health window closes on the flush tick rather than on a timer of its own. One timer
|
|
1474
|
+
// is one thing holding the event loop's attention, and a second interval whose only job is
|
|
1475
|
+
// to check a clock is a second thing a serverless freeze can strand mid-cycle.
|
|
1476
|
+
this.rollHealth(false);
|
|
1477
|
+
void this.flush();
|
|
1478
|
+
});
|
|
1479
|
+
}, this.cfg.flushIntervalMs || 2000);
|
|
1480
|
+
if (typeof this._timer.unref === 'function') this._timer.unref();
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
_stopTimer() {
|
|
1484
|
+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
emit(event) {
|
|
1488
|
+
return guard('client.emit', () => this.sink.enqueue(event), false);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Close the `service_health` window if it is due, and emit it if there was anything in it.
|
|
1493
|
+
*
|
|
1494
|
+
* @param {boolean} force `heartbeat()`'s path — close the window now, and emit it even when empty.
|
|
1495
|
+
* @returns {boolean} whether an event was emitted.
|
|
1496
|
+
*/
|
|
1497
|
+
rollHealth(force) {
|
|
1498
|
+
return guard('client.rollHealth', () => {
|
|
1499
|
+
if (!force && !this.health.due(this.cfg.healthIntervalMs)) return false;
|
|
1500
|
+
const event = this.health.roll(this.sessionId, this.cfg, contract, force);
|
|
1501
|
+
if (!event) return false;
|
|
1502
|
+
this.emit(event);
|
|
1503
|
+
return true;
|
|
1504
|
+
}, false);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
emitHealth(checkpoint) {
|
|
1508
|
+
guard('client.health', () => {
|
|
1509
|
+
// One and only one closing record per process. `shutdown()` and the `exit` handler both want
|
|
1510
|
+
// to write it, and a duplicated stop makes the queue-depth series look like a restart loop.
|
|
1511
|
+
if (checkpoint === 'stop') {
|
|
1512
|
+
if (this._stopped) return;
|
|
1513
|
+
this._stopped = true;
|
|
1514
|
+
}
|
|
1515
|
+
this.emit(contract.pipelineHealth(this.sessionId, this.cfg, {
|
|
1516
|
+
instanceId: this.instanceId, checkpoint, counters: snapshot(),
|
|
1517
|
+
queueDepth: this.sink.depth(), collectorUp: this.sink.lastSendOk !== false,
|
|
1518
|
+
// Deliberately no `runtime` here, and it is the one pipeline_health field the two SDKs do
|
|
1519
|
+
// not share. Python's is `{transport_mode: thread|sync}` — which fork of its transport is
|
|
1520
|
+
// running — and Node has no transport modes to report, because it has one event loop and
|
|
1521
|
+
// there is no thread-vs-sync choice to make (PARITY.md §2). Emitting an empty object or a
|
|
1522
|
+
// made-up mode to make the shapes match would be inventing a fact to satisfy a diff.
|
|
1523
|
+
}));
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** @returns {Promise<boolean>} whether the queue emptied within the deadline. */
|
|
1528
|
+
flush(deadlineMs) {
|
|
1529
|
+
return guardAsync('client.flush', () => this.sink.flush(deadlineMs), false);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Final flush, then stop being a running thing.
|
|
1534
|
+
*
|
|
1535
|
+
* The timer is cleared *first*. A shutdown that leaves an interval armed means the last thing a
|
|
1536
|
+
* terminating process does is schedule work, and the `exit` handler then races a drain it did
|
|
1537
|
+
* not start.
|
|
1538
|
+
*/
|
|
1539
|
+
shutdown(deadlineMs) {
|
|
1540
|
+
return guardAsync('client.shutdown', async () => {
|
|
1541
|
+
this._stopTimer();
|
|
1542
|
+
this.emitHealth('stop');
|
|
1543
|
+
return await this.sink.flush(deadlineMs);
|
|
1544
|
+
}, false);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function getClient() { return _client; }
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* The client, creating a default one if the application never called `init()`.
|
|
1552
|
+
*
|
|
1553
|
+
* Auto-instrumentation can be armed by a `--import` flag the app author never edited, so "the app
|
|
1554
|
+
* never introduced itself" is a supported state. Capturing under `service: "unknown"` is right;
|
|
1555
|
+
* discarding the data because nobody called `init` is not.
|
|
1556
|
+
*/
|
|
1557
|
+
function ensureClient() {
|
|
1558
|
+
if (_off) return null;
|
|
1559
|
+
if (_client) return _client;
|
|
1560
|
+
_client = new Client(resolveConfig());
|
|
1561
|
+
_client.start();
|
|
1562
|
+
return _client;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Switched off, at the layer that can actually make it true.
|
|
1567
|
+
*
|
|
1568
|
+
* `src/index.js` and `src/index.cjs` short-circuit before reaching this module, which is what
|
|
1569
|
+
* makes the disabled import cheap. This flag is the *second* line: anything holding a direct
|
|
1570
|
+
* reference to the core — the hooks, a test, a re-export a bundler produced — must find the same
|
|
1571
|
+
* answer, or `NEXUS_ENABLED=0` is only a promise about one entry point. Read once at load, because
|
|
1572
|
+
* a switch that can flip mid-process would mean re-reading the environment on every call *and*
|
|
1573
|
+
* promising the SDK can arm itself later, which "no timers, no sockets, no handlers" cannot honour.
|
|
1574
|
+
*/
|
|
1575
|
+
let _off = !envEnabled();
|
|
1576
|
+
|
|
1577
|
+
function init(opts) {
|
|
1578
|
+
return guard('init', () => {
|
|
1579
|
+
const cfg = resolveConfig(opts);
|
|
1580
|
+
if (!cfg.enabled) { _off = true; return null; }
|
|
1581
|
+
if (!_client) {
|
|
1582
|
+
_client = new Client(cfg);
|
|
1583
|
+
_client.start();
|
|
1584
|
+
} else {
|
|
1585
|
+
// Re-init adopts the new identity and keeps the queue and the session. Tearing them down
|
|
1586
|
+
// would drop whatever is buffered and orphan any in-flight run.
|
|
1587
|
+
_client.cfg = cfg;
|
|
1588
|
+
_client.sink.cfg = cfg;
|
|
1589
|
+
if (cfg.sink && cfg.sink !== 'none') _client.sink.path = cfg.sink;
|
|
1590
|
+
}
|
|
1591
|
+
return _client;
|
|
1592
|
+
}, null);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function resetForTests() {
|
|
1596
|
+
if (_client) {
|
|
1597
|
+
_client._stopTimer();
|
|
1598
|
+
// Detach the lifecycle handlers too. `start()` adds an `exit` and a `beforeExit` listener, and
|
|
1599
|
+
// a suite that calls `init()` a few hundred times would otherwise accumulate them until Node
|
|
1600
|
+
// prints a MaxListenersExceededWarning — which reads like a leak in the SDK, is one in the
|
|
1601
|
+
// test harness, and would eventually make every test run noisy enough that a real warning got
|
|
1602
|
+
// lost in it. `removeListener` on a handler that already fired is a no-op.
|
|
1603
|
+
if (_client._onExit) process.removeListener('exit', _client._onExit);
|
|
1604
|
+
if (_client._onBeforeExit) process.removeListener('beforeExit', _client._onBeforeExit);
|
|
1605
|
+
}
|
|
1606
|
+
_client = null;
|
|
1607
|
+
_off = !envEnabled();
|
|
1608
|
+
_stack.length = 0;
|
|
1609
|
+
_expectations.length = 0;
|
|
1610
|
+
// The declarations map is module-global for the same reason `_expectations` is, and had to be
|
|
1611
|
+
// cleared here for the same reason: without it a test reads the previous test's declaration and
|
|
1612
|
+
// its assertions become statements about whatever ran before it.
|
|
1613
|
+
DECLARED.clear();
|
|
1614
|
+
// Process-global, and therefore leaks between tests unless it is reset here: once any test built
|
|
1615
|
+
// the AI bridge, every later test saw `instrumentation() === 'ai-sdk'` regardless of what it had
|
|
1616
|
+
// done. Resetting to the unarmed value is what makes the assertion "this call is what turned it
|
|
1617
|
+
// on" mean anything.
|
|
1618
|
+
_instrumentationMode = 'none';
|
|
1619
|
+
_gateHook = null;
|
|
1620
|
+
for (const k of Object.keys(counters)) delete counters[k];
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// ---------------------------------------------------------------------------------------------
|
|
1624
|
+
// explicit API — Python `api.py`
|
|
1625
|
+
// ---------------------------------------------------------------------------------------------
|
|
1626
|
+
|
|
1627
|
+
class Action {
|
|
1628
|
+
constructor(run, name, target) {
|
|
1629
|
+
this._run = run;
|
|
1630
|
+
this.name = name;
|
|
1631
|
+
this.target = target;
|
|
1632
|
+
this._t0 = Date.now();
|
|
1633
|
+
this._effect = {};
|
|
1634
|
+
this._blocked = false;
|
|
1635
|
+
this._reason = null;
|
|
1636
|
+
this._closed = false;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
effect(fields) {
|
|
1640
|
+
guard('action.effect', () => { Object.assign(this._effect, fields || {}); });
|
|
1641
|
+
return this;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
block(reason) {
|
|
1645
|
+
guard('action.block', () => { this._blocked = true; this._reason = reason; });
|
|
1646
|
+
return this;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
end(error) {
|
|
1650
|
+
guard('action.end', () => {
|
|
1651
|
+
if (this._closed) return;
|
|
1652
|
+
this._closed = true;
|
|
1653
|
+
this._run._actions += 1;
|
|
1654
|
+
if (this._blocked) this._run._blocked += 1;
|
|
1655
|
+
this._run._client.emit(contract.toolAction(this._run.sessionId, this._run._client.cfg, {
|
|
1656
|
+
toolName: this.name, action: 'invoke', target: this.target,
|
|
1657
|
+
blocked: this._blocked, reason: this._reason,
|
|
1658
|
+
durationMs: Date.now() - this._t0,
|
|
1659
|
+
runId: this._run.runId,
|
|
1660
|
+
effect: Object.keys(this._effect).length ? this._effect : null,
|
|
1661
|
+
error: error ? String(error && error.message ? error.message : error) : null,
|
|
1662
|
+
}));
|
|
1663
|
+
});
|
|
1664
|
+
return this;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
class Run {
|
|
1669
|
+
constructor(client, name, goalClass) {
|
|
1670
|
+
this._client = client;
|
|
1671
|
+
this.name = name;
|
|
1672
|
+
this.runId = crypto.randomUUID().replace(/-/g, '');
|
|
1673
|
+
this.sessionId = client.sessionId;
|
|
1674
|
+
this.goalClass = goalClass || null;
|
|
1675
|
+
this._t0 = Date.now();
|
|
1676
|
+
this._actions = 0;
|
|
1677
|
+
this._blocked = 0;
|
|
1678
|
+
this._closed = false;
|
|
1679
|
+
this._outcome = null;
|
|
1680
|
+
this._pushed = false;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
_start() {
|
|
1684
|
+
guard('run.start', () => {
|
|
1685
|
+
_stack.push(this);
|
|
1686
|
+
this._pushed = true;
|
|
1687
|
+
this._client.emit(contract.agentRun(this.sessionId, this._client.cfg, {
|
|
1688
|
+
runId: this.runId, name: this.name, phase: 'start', goalClass: this.goalClass,
|
|
1689
|
+
}));
|
|
1690
|
+
});
|
|
1691
|
+
return this;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** One effect on the world, bracketed. The enforcement seam lives here (WP-6). */
|
|
1695
|
+
/**
|
|
1696
|
+
* Open an action, **after asking policy whether it may happen.**
|
|
1697
|
+
*
|
|
1698
|
+
* This is the only gated call site in this SDK, and the only one that can be. Enforcement needs a
|
|
1699
|
+
* decision taken *before* the effect, which needs a call site the customer owns — the AI SDK seam
|
|
1700
|
+
* has no return channel to refuse on, and module hooks do not survive bundling. So: here, a
|
|
1701
|
+
* gateway, or nothing.
|
|
1702
|
+
*
|
|
1703
|
+
* **The gate runs outside the `guard`, and that is the whole point.** `guard` exists to stop an
|
|
1704
|
+
* SDK bug reaching the host, and it would happily swallow a `Denied` — turning a refusal into a
|
|
1705
|
+
* silently-permitted call, which is precisely the failure mode the AI seam has and this call site
|
|
1706
|
+
* exists to avoid. `Denied` is the one exception this SDK may raise, and the host opted into it
|
|
1707
|
+
* twice: the rule carried `enforce: true` and the call site did not decline.
|
|
1708
|
+
*
|
|
1709
|
+
* With no policy installed — the default — `_gateHook` is null and this is the same function it
|
|
1710
|
+
* was before. Nothing changes until a signed envelope arrives.
|
|
1711
|
+
*/
|
|
1712
|
+
action(name, target) {
|
|
1713
|
+
if (_gateHook !== null) {
|
|
1714
|
+
// Deliberately not inside `guard`. A hook that throws `Denied` must propagate; a hook that
|
|
1715
|
+
// throws anything else is a bug in policy, and `policy.check` already contains those and
|
|
1716
|
+
// degrades to allow, so nothing but a real denial can get out of here.
|
|
1717
|
+
_gateHook({ kind: 'tool_action', tool: name, target: target === undefined ? null : target });
|
|
1718
|
+
}
|
|
1719
|
+
return guard('run.action', () => new Action(this, name, target), INERT);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/** One **logical** model call. `attempts` because a vendor client retries internally. */
|
|
1723
|
+
usage(o) {
|
|
1724
|
+
guard('run.usage', () => {
|
|
1725
|
+
this._client.emit(contract.tokenUsage(this.sessionId, this._client.cfg, {
|
|
1726
|
+
model: o.model, provider: o.provider,
|
|
1727
|
+
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
1728
|
+
cacheReadTokens: o.cacheReadTokens, cacheWriteTokens: o.cacheWriteTokens,
|
|
1729
|
+
costUsd: o.costUsd, costSource: o.costSource,
|
|
1730
|
+
runId: this.runId, attempts: o.attempts, incomplete: o.incomplete,
|
|
1731
|
+
instrumentation: o.instrumentation,
|
|
1732
|
+
}));
|
|
1733
|
+
});
|
|
1734
|
+
return this;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* What the run achieved. `verifiedBy` is not ceremony: this event is tagged `behavior_trace`,
|
|
1739
|
+
* the class reserved for verifiable fact. With nothing named as the verifier, `verified` stays
|
|
1740
|
+
* absent — *"nothing was verified"* — rather than recording a claim as a fact.
|
|
1741
|
+
*/
|
|
1742
|
+
outcome(outcome, opts) {
|
|
1743
|
+
const o = opts || {};
|
|
1744
|
+
guard('run.outcome', () => {
|
|
1745
|
+
this._outcome = outcome;
|
|
1746
|
+
this._client.emit(contract.turnOutcome(this.sessionId, this._client.cfg, {
|
|
1747
|
+
runId: this.runId, outcome, verified: o.verified, verifiedBy: o.verifiedBy,
|
|
1748
|
+
actions: this._actions, blocked: this._blocked,
|
|
1749
|
+
}));
|
|
1750
|
+
});
|
|
1751
|
+
return this;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/** Close the run. Idempotent; safe to call from a `finally`. */
|
|
1755
|
+
end(error) {
|
|
1756
|
+
guard('run.end', () => {
|
|
1757
|
+
if (this._closed) return;
|
|
1758
|
+
this._closed = true;
|
|
1759
|
+
if (this._pushed) {
|
|
1760
|
+
const i = _stack.lastIndexOf(this);
|
|
1761
|
+
if (i !== -1) _stack.splice(i, 1);
|
|
1762
|
+
this._pushed = false;
|
|
1763
|
+
}
|
|
1764
|
+
this._client.emit(contract.agentRun(this.sessionId, this._client.cfg, {
|
|
1765
|
+
runId: this.runId, name: this.name, phase: 'end', goalClass: this.goalClass,
|
|
1766
|
+
durationMs: Date.now() - this._t0, actions: this._actions,
|
|
1767
|
+
error: error ? String(error && error.message ? error.message : error) : null,
|
|
1768
|
+
}));
|
|
1769
|
+
if (this._outcome === null && error) {
|
|
1770
|
+
this.outcome('error', { verified: false, verifiedBy: 'exception' });
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
return this;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function agent(name, opts) {
|
|
1778
|
+
return guard('agent', () => {
|
|
1779
|
+
const c = ensureClient();
|
|
1780
|
+
if (!c) return INERT; // switched off: no client was ever built
|
|
1781
|
+
return new Run(c, name, opts && opts.goalClass)._start();
|
|
1782
|
+
}, INERT);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Scoped form. `AsyncLocalStorage.run` is the only shape in which the current run survives `await`
|
|
1787
|
+
* correctly under concurrency; the returned-object form from `agent()` cannot, and both are offered
|
|
1788
|
+
* rather than pretending otherwise.
|
|
1789
|
+
*/
|
|
1790
|
+
function withAgent(name, opts, fn) {
|
|
1791
|
+
if (typeof opts === 'function') { fn = opts; opts = undefined; }
|
|
1792
|
+
const run = agent(name, opts);
|
|
1793
|
+
return als.run(run, async () => {
|
|
1794
|
+
try {
|
|
1795
|
+
const result = await fn(run);
|
|
1796
|
+
run.end();
|
|
1797
|
+
return result;
|
|
1798
|
+
} catch (err) {
|
|
1799
|
+
run.end(err);
|
|
1800
|
+
throw err; // never swallow the application's exception
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function action(name, target) {
|
|
1806
|
+
return guard('action', () => {
|
|
1807
|
+
const run = currentRun();
|
|
1808
|
+
if (run) return run.action(name, target);
|
|
1809
|
+
const c = ensureClient();
|
|
1810
|
+
if (!c) return INERT;
|
|
1811
|
+
// Outside any run: capture under an implicit parent rather than dropping the action.
|
|
1812
|
+
const synthetic = new Run(c, 'unattributed');
|
|
1813
|
+
synthetic._closed = true;
|
|
1814
|
+
return new Action(synthetic, name, target);
|
|
1815
|
+
}, INERT);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* Drain the queue within a deadline.
|
|
1820
|
+
*
|
|
1821
|
+
* Asynchronous, and that is not an implementation detail: the calling thread must never perform
|
|
1822
|
+
* I/O, so there is no synchronous form of this to offer. Callers that cannot await — an `exit`
|
|
1823
|
+
* handler — are served by the sink's own last resort, which is deliberately not exported.
|
|
1824
|
+
*
|
|
1825
|
+
* @returns {Promise<boolean>}
|
|
1826
|
+
*/
|
|
1827
|
+
function flush(deadlineMs) {
|
|
1828
|
+
const c = _client;
|
|
1829
|
+
return c ? c.flush(deadlineMs) : Promise.resolve(true);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** Final flush, then stop. @returns {Promise<boolean>} */
|
|
1833
|
+
function shutdown(deadlineMs) {
|
|
1834
|
+
const c = _client;
|
|
1835
|
+
return c ? c.shutdown(deadlineMs) : Promise.resolve(true);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
1839
|
+
// operate plane — the explicit calls (ANCHOR-INTEGRATION §6.2 and §6.4)
|
|
1840
|
+
//
|
|
1841
|
+
// Level 2 and Level 3 in the Python SDK's ladder: things auto-instrumentation cannot infer,
|
|
1842
|
+
// because they are facts about the *deployment* and about *what the data meant*, not about which
|
|
1843
|
+
// functions were called. Every one of them is guarded, so a mistake here cannot reach the host.
|
|
1844
|
+
// ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* §6.4 — self-report a deployment, as a fallback for an estate with no CI or cloud connector.
|
|
1848
|
+
*
|
|
1849
|
+
* nexus.deployment({ version: '2026.8.1', commit: SHA, env: 'prod' });
|
|
1850
|
+
*
|
|
1851
|
+
* Anything omitted is taken from the resolved config, so on Vercel the whole call is usually
|
|
1852
|
+
* `nexus.deployment()`. **`detected_by` is always `'self'` and is not a parameter.** A process
|
|
1853
|
+
* asserting its own deployment is the weakest evidence on the plane — it is precisely what a
|
|
1854
|
+
* shadow deploy would also produce — and a caller able to claim `'ci'` could erase that
|
|
1855
|
+
* distinction with one argument.
|
|
1856
|
+
*
|
|
1857
|
+
* Emits nothing when neither a version nor a commit is known: a deployment record that identifies
|
|
1858
|
+
* no software is a row that makes the ledger longer without making it truer. The refusal is
|
|
1859
|
+
* counted so it is discoverable rather than mysterious.
|
|
1860
|
+
*
|
|
1861
|
+
* @returns {boolean} whether a record was emitted.
|
|
1862
|
+
*/
|
|
1863
|
+
function deployment(o) {
|
|
1864
|
+
return guard('deployment', () => {
|
|
1865
|
+
const c = ensureClient();
|
|
1866
|
+
if (!c) return false;
|
|
1867
|
+
const cfg = c.cfg;
|
|
1868
|
+
const opts = o || {};
|
|
1869
|
+
|
|
1870
|
+
const version = opts.version || cfg.version || null;
|
|
1871
|
+
const commit = opts.commit || cfg.commit || null;
|
|
1872
|
+
if (!version && !commit) { incr('deployment_unidentified'); return false; }
|
|
1873
|
+
|
|
1874
|
+
c.emit(contract.deployment(c.sessionId, cfg, {
|
|
1875
|
+
deploymentId: opts.deploymentId || cfg.deploymentId || selfDeploymentId(cfg, version, commit),
|
|
1876
|
+
appId: opts.appId || cfg.application || null,
|
|
1877
|
+
env: opts.env || cfg.env,
|
|
1878
|
+
version,
|
|
1879
|
+
commit,
|
|
1880
|
+
repo: opts.repo || cfg.repo || null,
|
|
1881
|
+
actor: opts.actor || null,
|
|
1882
|
+
// Absent unless the caller supplies them. The process's own start time is not when the
|
|
1883
|
+
// deployment finished, and `outcome: 'succeeded'` because we are running is a claim about a
|
|
1884
|
+
// rollout we can only see one replica of.
|
|
1885
|
+
startedTs: opts.startedTs || null,
|
|
1886
|
+
finishedTs: opts.finishedTs || null,
|
|
1887
|
+
outcome: opts.outcome || null,
|
|
1888
|
+
rollbackOf: opts.rollbackOf || null,
|
|
1889
|
+
provenance: cfg.provenance,
|
|
1890
|
+
}));
|
|
1891
|
+
return true;
|
|
1892
|
+
}, false);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* A deployment id for a self-report on a platform that did not supply one.
|
|
1897
|
+
*
|
|
1898
|
+
* Deterministic, and that is the entire requirement: forty replicas of one release must produce
|
|
1899
|
+
* one deployment id, or the drift lanes show a rollout as forty deployments. Derived from the
|
|
1900
|
+
* identity of the software rather than from a clock or a random source for the same reason. The
|
|
1901
|
+
* `self:` prefix says out loud that nothing issued this id — we composed it.
|
|
1902
|
+
*/
|
|
1903
|
+
function selfDeploymentId(cfg, version, commit) {
|
|
1904
|
+
return 'self:' + fingerprint([cfg.service, cfg.env, version || '', commit || ''].join('|'));
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* §6.2 — the silent-failure primitive, and the highest-value call on the plane.
|
|
1909
|
+
*
|
|
1910
|
+
* const io = nexus.integration('salesforce', { kind: 'crm' });
|
|
1911
|
+
* try {
|
|
1912
|
+
* const rows = await client.fetchAccounts();
|
|
1913
|
+
* io.data({ rows: rows.length, watermark: rows[rows.length - 1].updatedAt });
|
|
1914
|
+
* } catch (err) {
|
|
1915
|
+
* io.failed(err, { errorClass: 'timeout' });
|
|
1916
|
+
* } finally {
|
|
1917
|
+
* io.end();
|
|
1918
|
+
* }
|
|
1919
|
+
*
|
|
1920
|
+
* or, bracketed for you, {@link withIntegration}.
|
|
1921
|
+
*
|
|
1922
|
+
* **The two registers are separate and must stay separate.** `end()` records *liveness* — the
|
|
1923
|
+
* call completed. `data({ watermark })` records *freshness* — the newest business timestamp we
|
|
1924
|
+
* actually saw. "The call succeeded", "the call succeeded and returned zero rows", and "the
|
|
1925
|
+
* newest row is three days old" are three different facts, and the PRD's headline case (API
|
|
1926
|
+
* healthy, data 72 h stale) is only expressible because no single field can express it. Every
|
|
1927
|
+
* competitor's one green dot is structurally incapable of saying it.
|
|
1928
|
+
*
|
|
1929
|
+
* A handle that is never ended emits nothing. That is correct: we did not observe an outcome, and
|
|
1930
|
+
* an unobserved probe must not become a recorded one.
|
|
1931
|
+
*/
|
|
1932
|
+
function integration(name, opts) {
|
|
1933
|
+
return guard('integration', () => {
|
|
1934
|
+
const c = ensureClient();
|
|
1935
|
+
if (!c) return INERT;
|
|
1936
|
+
return new Integration(c, name, opts || {});
|
|
1937
|
+
}, INERT);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
class Integration {
|
|
1941
|
+
constructor(client, name, opts) {
|
|
1942
|
+
this._client = client;
|
|
1943
|
+
this.name = name;
|
|
1944
|
+
this._opts = opts;
|
|
1945
|
+
this._t0 = Date.now();
|
|
1946
|
+
this._data = null;
|
|
1947
|
+
this._error = null;
|
|
1948
|
+
this._closed = false;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/**
|
|
1952
|
+
* Freshness. `watermark` is the newest **business** timestamp observed — the `updated_at` of
|
|
1953
|
+
* the last row, not our own clock, which would make a probe that fetched nothing look fresh.
|
|
1954
|
+
*
|
|
1955
|
+
* `rows: 0` means *we counted, and there were none*. No `rows` key means *we did not count*.
|
|
1956
|
+
* Nothing here invents a default, because `dropNulls` cannot restore a distinction a caller's
|
|
1957
|
+
* `|| 0` already destroyed.
|
|
1958
|
+
*/
|
|
1959
|
+
data(fields) {
|
|
1960
|
+
guard('integration.data', () => {
|
|
1961
|
+
const f = fields || {};
|
|
1962
|
+
const d = this._data || (this._data = {});
|
|
1963
|
+
// Only what was SUPPLIED, and that is the fix rather than a style preference. This used to
|
|
1964
|
+
// assign all three unconditionally, so `io.schema(row)` followed by `io.data({rows})` wrote
|
|
1965
|
+
// `schemaFingerprint: undefined` straight over the fingerprint — the call order a caller
|
|
1966
|
+
// naturally writes, silently discarding the field. Absent and supplied-as-undefined have to
|
|
1967
|
+
// stay different here for the same reason `rows: 0` and no `rows` key do.
|
|
1968
|
+
if (f.rows !== undefined) d.rows = f.rows;
|
|
1969
|
+
if (f.schemaFingerprint !== undefined) d.schemaFingerprint = f.schemaFingerprint;
|
|
1970
|
+
// `watermark` is the ergonomic name and the one the Python SDK uses; `last_data_ts` is the
|
|
1971
|
+
// wire name. Both accepted, one meaning.
|
|
1972
|
+
const wm = f.watermark !== undefined ? f.watermark : f.lastDataTs;
|
|
1973
|
+
if (wm !== undefined) d.lastDataTs = isoOrNull(wm);
|
|
1974
|
+
});
|
|
1975
|
+
return this;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/**
|
|
1979
|
+
* Say whether the credential was accepted. Only the caller knows; we never guess.
|
|
1980
|
+
*
|
|
1981
|
+
* It was previously reachable only as a constructor option (`integration(name, { authOk })`),
|
|
1982
|
+
* which meant it had to be known before the call was made — and whether a credential was
|
|
1983
|
+
* accepted is precisely the thing you learn afterwards. The Python SDK has had `io.auth(ok)`
|
|
1984
|
+
* since the operate plane landed, so the same instrumentation could report `auth_ok` from a
|
|
1985
|
+
* Python service and not from a Node one.
|
|
1986
|
+
*/
|
|
1987
|
+
auth(ok) {
|
|
1988
|
+
guard('integration.auth', () => { this._authOk = !!ok; });
|
|
1989
|
+
return this;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* Fingerprint the response's **shape**, so a silent schema break is visible before the rows stop
|
|
1994
|
+
* matching. Keys only for an object; never values, at any tier.
|
|
1995
|
+
*
|
|
1996
|
+
* A port of `nexus/api.py:Integration.schema`, and it has to be an exact one: the fingerprint is
|
|
1997
|
+
* a wire value two producers write into one column, so a Node service and a Python service
|
|
1998
|
+
* reading the same API must produce the same digest or "the schema changed" fires on the day a
|
|
1999
|
+
* customer migrated a service between languages. Same key bound (64), same cap (128 keys), same
|
|
2000
|
+
* sort, same `,` join, same `sha256[:12]`.
|
|
2001
|
+
*
|
|
2002
|
+
* **The non-object case cannot agree and does not pretend to.** Python fingerprints
|
|
2003
|
+
* `type(sample).__name__`, and JavaScript has no `list`, no `str`, and one `number` where Python
|
|
2004
|
+
* has `int` and `float`. Mapping `Array`→`list` would be this SDK asserting the sample came from
|
|
2005
|
+
* a Python process. The object case is the one a response sample actually takes; the scalar case
|
|
2006
|
+
* is recorded as a difference in PARITY.md §3b rather than papered over.
|
|
2007
|
+
*/
|
|
2008
|
+
schema(sample) {
|
|
2009
|
+
guard('integration.schema', () => {
|
|
2010
|
+
let shape;
|
|
2011
|
+
if (sample && typeof sample === 'object' && !Array.isArray(sample)) {
|
|
2012
|
+
shape = Object.keys(sample).slice(0, 128).map((k) => String(k).slice(0, 64)).sort().join(',');
|
|
2013
|
+
} else if (Array.isArray(sample)) {
|
|
2014
|
+
shape = 'Array';
|
|
2015
|
+
} else {
|
|
2016
|
+
shape = sample === null ? 'null' : typeof sample;
|
|
2017
|
+
}
|
|
2018
|
+
this._data = Object.assign(this._data || {}, { schemaFingerprint: fingerprint(shape) });
|
|
2019
|
+
});
|
|
2020
|
+
return this;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/** Liveness, negative. The call did not complete. */
|
|
2024
|
+
failed(error, opts) {
|
|
2025
|
+
guard('integration.failed', () => {
|
|
2026
|
+
this._error = { error, errorClass: (opts && opts.errorClass) || null };
|
|
2027
|
+
});
|
|
2028
|
+
return this;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** Python spells it `fail`. One meaning, two spellings, so neither reads as missing. */
|
|
2032
|
+
fail(error, opts) {
|
|
2033
|
+
return this.failed(error, opts);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** Close the probe and emit exactly one observation. Idempotent. */
|
|
2037
|
+
end(error) {
|
|
2038
|
+
guard('integration.end', () => {
|
|
2039
|
+
if (this._closed) return;
|
|
2040
|
+
this._closed = true;
|
|
2041
|
+
if (error && !this._error) this._error = { error, errorClass: null };
|
|
2042
|
+
const failed = this._error !== null;
|
|
2043
|
+
const d = this._data || {};
|
|
2044
|
+
const cfg = this._client.cfg;
|
|
2045
|
+
this._client.emit(contract.integrationProbe(this._client.sessionId, cfg, {
|
|
2046
|
+
integration: this.name,
|
|
2047
|
+
appId: this._opts.appId || cfg.application || null,
|
|
2048
|
+
service: this._opts.service || cfg.service,
|
|
2049
|
+
kind: this._opts.kind || null,
|
|
2050
|
+
observedTs: now(),
|
|
2051
|
+
// Observed, not assumed: the bracketed call either returned or it did not.
|
|
2052
|
+
integrationUp: !failed,
|
|
2053
|
+
authOk: this._authOk === undefined ? this._opts.authOk : this._authOk,
|
|
2054
|
+
latencyMs: Date.now() - this._t0,
|
|
2055
|
+
lastDataTs: d.lastDataTs,
|
|
2056
|
+
rows: d.rows,
|
|
2057
|
+
schemaFingerprint: d.schemaFingerprint,
|
|
2058
|
+
error: failed ? errText(this._error.error) : null,
|
|
2059
|
+
errorClass: failed ? this._error.errorClass : null,
|
|
2060
|
+
}));
|
|
2061
|
+
});
|
|
2062
|
+
return this;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* The bracketed form of {@link integration}, and the closest Node has to Python's
|
|
2068
|
+
* `with nexus.integration(...) as io:`. The probe is closed on the way out including on throw, and
|
|
2069
|
+
* the application's exception is re-thrown unchanged.
|
|
2070
|
+
*/
|
|
2071
|
+
function withIntegration(name, opts, fn) {
|
|
2072
|
+
if (typeof opts === 'function') { fn = opts; opts = undefined; }
|
|
2073
|
+
const io = integration(name, opts);
|
|
2074
|
+
return (async () => {
|
|
2075
|
+
try {
|
|
2076
|
+
const result = await fn(io);
|
|
2077
|
+
io.end();
|
|
2078
|
+
return result;
|
|
2079
|
+
} catch (err) {
|
|
2080
|
+
io.failed(err);
|
|
2081
|
+
io.end();
|
|
2082
|
+
throw err;
|
|
2083
|
+
}
|
|
2084
|
+
})();
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* Declared expectations, so the alarm has something to be silent *against*.
|
|
2089
|
+
*
|
|
2090
|
+
* Deduplicated per process: a declaration made inside a scheduled function would otherwise be
|
|
2091
|
+
* re-emitted on every tick, and ten thousand identical declarations is noise that makes the one
|
|
2092
|
+
* that changed invisible.
|
|
2093
|
+
*/
|
|
2094
|
+
const _expectations = [];
|
|
2095
|
+
|
|
2096
|
+
/** @see the comment at the `key` below. */
|
|
2097
|
+
const KEY_SEP = String.fromCharCode(0);
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* §6.2 — declare that data is expected within a window.
|
|
2101
|
+
*
|
|
2102
|
+
* nexus.expectsData('crm_sync', { within: '24h' });
|
|
2103
|
+
*
|
|
2104
|
+
* An expectation with no matching `integration_probe` inside its window is the silent-failure
|
|
2105
|
+
* alarm. Note the asymmetry that keeps it honest: the alarm fires on **absence of evidence
|
|
2106
|
+
* against a declared expectation**, never on absence alone — absence alone is indistinguishable
|
|
2107
|
+
* from "nobody ever declared this", and an alarm that fires on that means nothing within a week.
|
|
2108
|
+
*
|
|
2109
|
+
* ── The declaration stays in this process, and does not become an event ──────────────────────
|
|
2110
|
+
*
|
|
2111
|
+
* This function used to emit a `data_expectation` event. It does not any more, and the removal is
|
|
2112
|
+
* a parity fix rather than a feature being dropped.
|
|
2113
|
+
*
|
|
2114
|
+
* `data_expectation` exists nowhere else in the system. `nexus_devtools/events.py` has no builder
|
|
2115
|
+
* for it, and it is therefore absent from the 51 `$defs` of `contract/events.v1.json`, which is
|
|
2116
|
+
* generated from those builders. The Python SDK reaches exactly this point and stops —
|
|
2117
|
+
* `nexus/api.py` holds its `DECLARED` map in-process under a TODO that says the declaration has no
|
|
2118
|
+
* wire representation and that inventing an event type ahead of the artifact is forbidden. Until
|
|
2119
|
+
* that event exists, Python delivers the *probe* half automatically and the alarm cannot fire from
|
|
2120
|
+
* code alone.
|
|
2121
|
+
*
|
|
2122
|
+
* This SDK invented the type anyway, which is the same rule broken from the other side: a second
|
|
2123
|
+
* producer writing a type the ledger has no model for. It was very likely dead on the wire in any
|
|
2124
|
+
* case — a collector validating against the schema has nothing to validate it against.
|
|
2125
|
+
*
|
|
2126
|
+
* Whether that event *should* exist is a contract decision for the humans who own
|
|
2127
|
+
* `nexus_devtools/events.py`. It is not something either SDK settles by emitting it and letting the
|
|
2128
|
+
* schema be regenerated to match, because that ratifies the invention instead of deciding it. When
|
|
2129
|
+
* a builder lands there, this is the place that changes — and `test/contract.test.mjs` fails the
|
|
2130
|
+
* day the two lists diverge again, in either direction.
|
|
2131
|
+
*
|
|
2132
|
+
* The declaration is still recorded here, so an in-process reader (and a future exporter) can see
|
|
2133
|
+
* it, and so the duplicate-suppression below still means something.
|
|
2134
|
+
*
|
|
2135
|
+
* @returns {boolean} whether this was a new declaration (`false` on a repeat).
|
|
2136
|
+
*/
|
|
2137
|
+
/**
|
|
2138
|
+
* Close the current `service_health` window now and emit it, even if nothing was measured.
|
|
2139
|
+
*
|
|
2140
|
+
* The honest form of "this process is alive". A window with a timestamp and **no quantities** — no
|
|
2141
|
+
* `requests`, no `errors`, no percentiles — because a process with no request loop still has
|
|
2142
|
+
* something true to say and `requests: 0` is not it. Zero observed spans far more often means "this
|
|
2143
|
+
* service never calls `run.action`" than it means an outage, and a fabricated zero reads on a
|
|
2144
|
+
* console exactly like a measured one.
|
|
2145
|
+
*
|
|
2146
|
+
* Call it from a cron, a liveness probe, or a worker that processes a queue slowly.
|
|
2147
|
+
*
|
|
2148
|
+
* @returns {boolean} whether an event was emitted.
|
|
2149
|
+
*/
|
|
2150
|
+
function heartbeat() {
|
|
2151
|
+
return guard('heartbeat', () => {
|
|
2152
|
+
const c = ensureClient();
|
|
2153
|
+
if (!c) return false;
|
|
2154
|
+
return c.rollHealth(true);
|
|
2155
|
+
}, false);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function expectsData(name, opts) {
|
|
2159
|
+
return guard('expectsData', () => {
|
|
2160
|
+
const c = ensureClient();
|
|
2161
|
+
if (!c) return false;
|
|
2162
|
+
const o = opts || {};
|
|
2163
|
+
const within = String(o.within || '').trim();
|
|
2164
|
+
if (!within) { incr('expectation_without_window'); return false; }
|
|
2165
|
+
|
|
2166
|
+
// A NUL separator, deliberately: it cannot occur in an integration name or in a window
|
|
2167
|
+
// spelling, so the composite key cannot collide the way a space could. Built with
|
|
2168
|
+
// `fromCharCode` so that no escape sequence ever appears in this source file.
|
|
2169
|
+
const key = name + KEY_SEP + within;
|
|
2170
|
+
if (_expectations.indexOf(key) !== -1) return false;
|
|
2171
|
+
_expectations.push(key);
|
|
2172
|
+
|
|
2173
|
+
DECLARED.set(key, {
|
|
2174
|
+
integration: name,
|
|
2175
|
+
appId: o.appId || c.cfg.application || null,
|
|
2176
|
+
within,
|
|
2177
|
+
// Absent when the spelling could not be parsed. A wrong `withinMs` silently retimes
|
|
2178
|
+
// somebody's alarm; an absent one makes a reader fall back to `within` verbatim.
|
|
2179
|
+
withinMs: parseWithin(within),
|
|
2180
|
+
declaredBy: o.declaredBy || null,
|
|
2181
|
+
declaredTs: o.declaredTs || now(),
|
|
2182
|
+
});
|
|
2183
|
+
incr('expectation_declared');
|
|
2184
|
+
return true;
|
|
2185
|
+
}, false);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* The in-process declarations, mirroring Python's `api.DECLARED`.
|
|
2190
|
+
*
|
|
2191
|
+
* Exposed so that the half of the feature that works is inspectable, and so that the day event 34
|
|
2192
|
+
* exists there is one place holding everything an exporter would need. Read-only by convention;
|
|
2193
|
+
* `declarations()` hands back a copy.
|
|
2194
|
+
*/
|
|
2195
|
+
const DECLARED = new Map();
|
|
2196
|
+
|
|
2197
|
+
/** A snapshot of what this process has declared. Empty unless `expectsData` was called. */
|
|
2198
|
+
function declarations() {
|
|
2199
|
+
return guard('declarations', () => Array.from(DECLARED.values()).map((d) => ({ ...d })), []);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/** `24h` · `30m` · `90s` · `7d` · `PT24H`. Anything else is `null` — never a guess. */
|
|
2203
|
+
function parseWithin(text) {
|
|
2204
|
+
const s = String(text).trim();
|
|
2205
|
+
const plain = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i.exec(s);
|
|
2206
|
+
const UNIT = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 };
|
|
2207
|
+
if (plain) return Math.round(parseFloat(plain[1]) * UNIT[plain[2].toLowerCase()]);
|
|
2208
|
+
const iso = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(s);
|
|
2209
|
+
if (iso && s.length > 1 && /\d/.test(s)) {
|
|
2210
|
+
const d = Number(iso[1] || 0), h = Number(iso[2] || 0), m = Number(iso[3] || 0);
|
|
2211
|
+
const sec = Number(iso[4] || 0);
|
|
2212
|
+
return Math.round(((d * 24 + h) * 60 + m) * 60000 + sec * 1000);
|
|
2213
|
+
}
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* A business timestamp, normalised the way `nexus/api.py:_watermark` normalises one.
|
|
2219
|
+
*
|
|
2220
|
+
* The string branch is the one that changed, and it changed to STOP reinterpreting. This used to
|
|
2221
|
+
* parse a string and re-serialise it, so a caller's `"2026-08-30T12:00:00Z"` — read straight off
|
|
2222
|
+
* their own record — came back as `"2026-08-30T12:00:00.000Z"`, and the same column held two
|
|
2223
|
+
* timestamp formats depending on which SDK wrote the row. Worse, a timestamp `Date` could not
|
|
2224
|
+
* parse was silently dropped to `null`, which the console draws as *no freshness data at all*:
|
|
2225
|
+
* the exact signal the silent-failure alarm exists to raise, deleted on the way to it.
|
|
2226
|
+
*
|
|
2227
|
+
* Python's rule, and now this one: a string is used as the caller gave it, bounded. A caller read
|
|
2228
|
+
* it off their own record; we are not in the business of reinterpreting somebody's format.
|
|
2229
|
+
*
|
|
2230
|
+
* **One unit difference remains, and it is deliberate.** A bare number here is epoch
|
|
2231
|
+
* milliseconds, because that is what `Date.now()` returns and what a JavaScript caller will pass;
|
|
2232
|
+
* Python reads epoch *seconds*, because that is what `time.time()` returns. Each is its own
|
|
2233
|
+
* language's convention, and picking one would make the other SDK's idiomatic call wrong.
|
|
2234
|
+
* PARITY.md §3b records it.
|
|
2235
|
+
*/
|
|
2236
|
+
function isoOrNull(v) {
|
|
2237
|
+
if (v === null || v === undefined || v === '') return null;
|
|
2238
|
+
if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v.toISOString();
|
|
2239
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
2240
|
+
return v <= 0 ? null : new Date(v).toISOString();
|
|
2241
|
+
}
|
|
2242
|
+
const text = String(v).trim();
|
|
2243
|
+
return text ? text.slice(0, 64) : null;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function errText(err) {
|
|
2247
|
+
if (err === null || err === undefined) return null;
|
|
2248
|
+
return String(err && err.message ? err.message : err);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// ---------------------------------------------------------------------------------------------
|
|
2252
|
+
// serverless — the lifecycle Node cannot infer
|
|
2253
|
+
//
|
|
2254
|
+
// A serverless platform freezes the sandbox the instant the handler returns. A background flusher
|
|
2255
|
+
// does not run late there; it does not run at all. Python solves this with an explicit
|
|
2256
|
+
// `instrument_lambda_handler`, and the same thing has to be explicit here, because there is no
|
|
2257
|
+
// signal a library can read that says "you are about to be frozen".
|
|
2258
|
+
//
|
|
2259
|
+
// This is stated rather than papered over: **without one of the two mechanisms below, the last
|
|
2260
|
+
// buffered events of every serverless invocation are lost.** Not delayed — lost. An SDK that let a
|
|
2261
|
+
// customer believe otherwise would be reporting a coverage number it knows is wrong.
|
|
2262
|
+
// ---------------------------------------------------------------------------------------------
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* Hand the host a promise it will keep the sandbox alive for.
|
|
2266
|
+
*
|
|
2267
|
+
* On Vercel this is `waitUntil` from `@vercel/functions`; it can also be given to `init()`. With
|
|
2268
|
+
* it, a flush outlives the response without delaying it. Without it, {@link instrumentHandler}
|
|
2269
|
+
* awaits the flush instead, which is correct but does add its duration to the invocation.
|
|
2270
|
+
*/
|
|
2271
|
+
function setWaitUntil(fn) {
|
|
2272
|
+
guard('setWaitUntil', () => {
|
|
2273
|
+
const c = ensureClient();
|
|
2274
|
+
if (c) c.cfg.waitUntil = typeof fn === 'function' ? fn : null;
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Wrap a serverless handler so the queue is drained before the sandbox freezes.
|
|
2280
|
+
*
|
|
2281
|
+
* export const handler = nexus.instrumentHandler(async (event) => { … });
|
|
2282
|
+
*
|
|
2283
|
+
* The application's return value and its exceptions pass through untouched — the flush happens in
|
|
2284
|
+
* a `finally`, so a handler that threw is still accounted for, which is exactly the invocation
|
|
2285
|
+
* whose telemetry matters most.
|
|
2286
|
+
*/
|
|
2287
|
+
function instrumentHandler(handler, opts) {
|
|
2288
|
+
if (typeof handler !== 'function') return handler;
|
|
2289
|
+
const budgetMs = (opts && opts.budgetMs) || 1000;
|
|
2290
|
+
return async function nexusInstrumentedHandler(...args) {
|
|
2291
|
+
try {
|
|
2292
|
+
return await handler.apply(this, args);
|
|
2293
|
+
} finally {
|
|
2294
|
+
await guardAsync('instrumentHandler.flush', async () => {
|
|
2295
|
+
const c = _client;
|
|
2296
|
+
if (!c) return;
|
|
2297
|
+
const w = c.cfg.waitUntil;
|
|
2298
|
+
// With `waitUntil` the platform owns the wait, so the response is not held for us.
|
|
2299
|
+
if (w) { w(c.flush(budgetMs)); return; }
|
|
2300
|
+
await c.flush(budgetMs);
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// ---------------------------------------------------------------------------------------------
|
|
2307
|
+
// instrumentation registry — shared by the ESM hook thread's shim output and the CJS hook
|
|
2308
|
+
// ---------------------------------------------------------------------------------------------
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* The policy gate, installed by `src/policy/index.cjs` when a customer imports it.
|
|
2312
|
+
*
|
|
2313
|
+
* A registration hook rather than a `require`, because `policy` requires `core` and the reverse
|
|
2314
|
+
* would be a cycle. It also keeps the core honest: with nothing registered there is no policy code
|
|
2315
|
+
* on the request path at all, which is what makes "adding the import changes no behaviour until an
|
|
2316
|
+
* envelope arrives" literally true rather than approximately so.
|
|
2317
|
+
*/
|
|
2318
|
+
let _gateHook = null;
|
|
2319
|
+
|
|
2320
|
+
/** Install the policy gate. Passing `null` removes it. */
|
|
2321
|
+
function setGateHook(fn) {
|
|
2322
|
+
_gateHook = typeof fn === 'function' ? fn : null;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
const INSTRUMENTED = Symbol.for('nexus.instrumented');
|
|
2326
|
+
let _instrumentationMode = 'none';
|
|
2327
|
+
|
|
2328
|
+
function describeInstrumentation() { return _instrumentationMode; }
|
|
2329
|
+
function noteInstrumentation(mode) { _instrumentationMode = mode; }
|
|
2330
|
+
|
|
2331
|
+
/**
|
|
2332
|
+
* Adapters, keyed by package name.
|
|
2333
|
+
*
|
|
2334
|
+
* `mutate` patches objects reachable from the namespace in place — this works identically in both
|
|
2335
|
+
* module systems, because an ESM namespace is a frozen record of *bindings*, and the objects those
|
|
2336
|
+
* bindings point at are ordinary mutable objects.
|
|
2337
|
+
*
|
|
2338
|
+
* `rebind` lists exports that must be replaced *as bindings*. Those cannot be patched in place in
|
|
2339
|
+
* ESM at all: `export function createClient` is an immutable binding in the importer. The only way
|
|
2340
|
+
* to intercept one is to rewrite the module's source so a different value is exported — which is
|
|
2341
|
+
* why the ESM hook generates a shim rather than just importing and mutating. This distinction is
|
|
2342
|
+
* the single largest cost difference between the CJS and ESM paths; see SCOPE.md §2.
|
|
2343
|
+
*/
|
|
2344
|
+
/**
|
|
2345
|
+
* **Empty in the published package, and that is the shipped state rather than a stub.**
|
|
2346
|
+
*
|
|
2347
|
+
* Module-hook auto-instrumentation is not part of this release (README, "What this does not do";
|
|
2348
|
+
* SCOPE.md §3 and §7/D5 for the measurements behind that). The registry itself stays, because the
|
|
2349
|
+
* AI SDK bridge and any future adapter need somewhere to record that a package was instrumented,
|
|
2350
|
+
* and because an empty table is what makes `instrumentation()` able to answer `"none"` honestly.
|
|
2351
|
+
*
|
|
2352
|
+
* The one adapter that used to live here wrapped the stand-in provider package the spike's hook
|
|
2353
|
+
* tests import. It now lives in `src/hooks/adapters-dev.cjs`, which the publish allowlist excludes
|
|
2354
|
+
* — a test double compiled into a customer's production dependency is a supply-chain smell even
|
|
2355
|
+
* when it is inert. `scripts/npm-guard.cjs` greps every shipped file for that package's name, so
|
|
2356
|
+
* this cannot quietly come back; that check is also why this comment does not spell it out.
|
|
2357
|
+
*/
|
|
2358
|
+
const ADAPTERS = Object.create(null);
|
|
2359
|
+
|
|
2360
|
+
/** Every capture path from a hook is guarded: a bad adapter must not break the customer's import. */
|
|
2361
|
+
function recordModelCall(provider, params, res, durationMs, err) {
|
|
2362
|
+
guard('instrument.record', () => {
|
|
2363
|
+
const client = ensureClient();
|
|
2364
|
+
const run = currentRun();
|
|
2365
|
+
const usage = (res && res.usage) || {};
|
|
2366
|
+
client.emit(contract.tokenUsage(client.sessionId, client.cfg, {
|
|
2367
|
+
model: (res && res.model) || (params && params.model) || 'unknown',
|
|
2368
|
+
provider,
|
|
2369
|
+
inputTokens: usage.input_tokens || 0,
|
|
2370
|
+
outputTokens: usage.output_tokens || 0,
|
|
2371
|
+
runId: run ? run.runId : undefined,
|
|
2372
|
+
instrumentation: describeInstrumentation(),
|
|
2373
|
+
incomplete: err ? true : undefined,
|
|
2374
|
+
}));
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function adapterFor(name) { return ADAPTERS[name] || null; }
|
|
2379
|
+
|
|
2380
|
+
/** Called from the injected ESM shim and from the CJS `Module._load` patch alike. */
|
|
2381
|
+
function instrumentNamespace(pkg, ns) {
|
|
2382
|
+
return guard('instrument.' + pkg, () => {
|
|
2383
|
+
const adapter = adapterFor(pkg);
|
|
2384
|
+
if (!adapter) return ns;
|
|
2385
|
+
adapter.mutate(ns);
|
|
2386
|
+
incr('instrumented.' + pkg);
|
|
2387
|
+
return ns;
|
|
2388
|
+
}, ns);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
module.exports = {
|
|
2392
|
+
SDK_VERSION, CONTRACT_VERSION, PRODUCER,
|
|
2393
|
+
EPISTEMIC_BEHAVIOR, EPISTEMIC_RATIONALISATION, EPISTEMIC_NARRATIVE,
|
|
2394
|
+
TIER_METADATA_ONLY, TIER_HASHED, TIER_FULL,
|
|
2395
|
+
contract, base, fingerprint, now, wireText, tieredText, redactPreview, rungs, estTokens,
|
|
2396
|
+
redact: redactor, pricing,
|
|
2397
|
+
guard, guardAsync, INERT,
|
|
2398
|
+
init, agent, withAgent, action, flush, shutdown,
|
|
2399
|
+
deployment, integration, withIntegration, expectsData, declarations, DECLARED, heartbeat,
|
|
2400
|
+
setWaitUntil, instrumentHandler,
|
|
2401
|
+
parseWithin, isoOrNull, selfDeploymentId, provenance,
|
|
2402
|
+
currentRun, counters: snapshot, incr, resetForTests, setGateHook,
|
|
2403
|
+
getClient, ensureClient, Run, Action, Client, Integration, Sink,
|
|
2404
|
+
ADAPTERS, adapterFor, instrumentNamespace, INSTRUMENTED, recordModelCall,
|
|
2405
|
+
// Exported for tests. `authAllowed` is a pure predicate and the security property it encodes —
|
|
2406
|
+
// a bearer token never rides plain HTTP off the machine — deserves a truth table asserted
|
|
2407
|
+
// directly rather than inferred from whether some request happened to carry a header.
|
|
2408
|
+
authAllowed, eventsUrl, resolveCollectorUrl,
|
|
2409
|
+
describeInstrumentation, noteInstrumentation,
|
|
2410
|
+
enabled: envEnabled,
|
|
2411
|
+
};
|