@swfte/nexus-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ };