@agent-watch-ai/otel 0.0.0-stage → 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/dist/post.js ADDED
@@ -0,0 +1,149 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { ROOT_CONTEXT, context, createContextKey } from '@opentelemetry/api';
4
+ /**
5
+ * The one way this package talks to Agent Watch: a JSON POST over `node:http`, shared by the
6
+ * processor's ingest batches and the gate's decision requests.
7
+ *
8
+ * - `node:http` rather than `fetch`: never through the gate's own `fetch` wrapper.
9
+ * - Its own keep-alive agent, so the sockets this package unrefs are never ones the application
10
+ * shares, and a request does not pay a handshake every time.
11
+ * - Sent under a context with tracing suppressed: the customer's HTTP instrumentation neither
12
+ * traces it nor injects trace headers into it.
13
+ * - Its socket is unref'd, so it never holds the process open, unless someone is waiting on the
14
+ * answer: the request's caller (`holdProcess`), or a caller of `heldWhile` such as an awaited
15
+ * forceFlush(). Then it is ref'd until the request settles or the wait ends.
16
+ * - Only https, or plain http to loopback: the bearer token never crosses a network in clear.
17
+ */
18
+ const TRAILING_SLASHES = /\/+$/;
19
+ const LOOPBACK_HOSTS = new Set(['localhost', '[::1]']);
20
+ // The key @opentelemetry/core's suppressTracing() sets, without depending on core.
21
+ const SUPPRESS_TRACING_KEY = createContextKey('OpenTelemetry SDK Context Key SUPPRESS_TRACING');
22
+ // A context with tracing suppressed, for every request this package sends.
23
+ const SUPPRESSED = ROOT_CONTEXT.setValue(SUPPRESS_TRACING_KEY, true);
24
+ /** A 2xx body longer than `maxBytes`. */
25
+ export class BodyTooLarge extends Error {
26
+ }
27
+ /** One Agent Watch route, with the token and the agent its requests use. */
28
+ export class Endpoint {
29
+ token;
30
+ url;
31
+ agent;
32
+ // Sockets of requests on the wire, each with whether its own caller holds the process for it.
33
+ sockets = new Map();
34
+ held = 0;
35
+ /**
36
+ * Join the route onto the base URL and give it its own agent.
37
+ * @throws {Error} Plain http to anything but loopback.
38
+ * @param token SDK token, sent as a bearer token.
39
+ * @param base Ingest base URL.
40
+ * @param path The route, joined onto `base`.
41
+ */
42
+ constructor(token, base, path) {
43
+ this.token = token;
44
+ this.url = new URL(path, `${base.replace(TRAILING_SLASHES, '')}/`);
45
+ if (this.url.protocol !== 'https:' && !(this.url.protocol === 'http:' && isLoopback(this.url.hostname))) {
46
+ throw new Error('AgentWatchSpanProcessor needs an https endpoint; plain http is accepted only on loopback, so the token never crosses a network in clear');
47
+ }
48
+ this.agent = this.url.protocol === 'https:' ? new https.Agent({ keepAlive: true }) : new http.Agent({ keepAlive: true });
49
+ }
50
+ /**
51
+ * POST `body` as JSON. Resolves with any status, a redirect included (never followed: it would
52
+ * carry the token elsewhere); rejects on a network error, an abort or an oversized body.
53
+ * @param body The JSON text.
54
+ * @param options See `PostOptions`.
55
+ * @returns The status, and the body when `maxBytes` asked for it and the status is 2xx.
56
+ */
57
+ postJson(body, options = {}) {
58
+ const { signal, maxBytes = 0, holdProcess = false } = options;
59
+ const holding = () => holdProcess === true || (holdProcess !== false && !holdProcess.aborted);
60
+ return context.with(SUPPRESSED, () => new Promise((resolve, reject) => {
61
+ let socket = null;
62
+ const settle = (done) => {
63
+ if (socket !== null) {
64
+ this.sockets.delete(socket);
65
+ socket.unref();
66
+ }
67
+ done();
68
+ };
69
+ const client = this.url.protocol === 'https:' ? https : http;
70
+ const outgoing = client.request(this.url, {
71
+ method: 'POST',
72
+ agent: this.agent,
73
+ signal,
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ 'Content-Length': Buffer.byteLength(body),
77
+ Authorization: `Bearer ${this.token}`,
78
+ 'User-Agent': 'agent-watch-otel'
79
+ }
80
+ }, (response) => {
81
+ const status = response.statusCode ?? 0;
82
+ const chunks = [];
83
+ let size = 0;
84
+ // A reset mid-body errors the response, and an 'error' with no listener would crash the host.
85
+ response.on('error', (error) => settle(() => reject(error)));
86
+ if (maxBytes === 0 || status < 200 || status > 299) {
87
+ response.resume();
88
+ settle(() => resolve({ status, text: '' }));
89
+ return;
90
+ }
91
+ response.on('data', (chunk) => {
92
+ size += chunk.length;
93
+ if (size > maxBytes) {
94
+ response.destroy();
95
+ settle(() => reject(new BodyTooLarge()));
96
+ return;
97
+ }
98
+ chunks.push(chunk);
99
+ });
100
+ response.on('end', () => settle(() => resolve({ status, text: Buffer.concat(chunks).toString('utf8') })));
101
+ });
102
+ outgoing.on('socket', (assigned) => {
103
+ socket = assigned;
104
+ this.sockets.set(assigned, holding());
105
+ assigned[holding() || this.held > 0 ? 'ref' : 'unref']();
106
+ });
107
+ outgoing.on('error', (error) => settle(() => reject(error)));
108
+ if (holdProcess instanceof AbortSignal) {
109
+ holdProcess.addEventListener('abort', () => {
110
+ if (socket !== null && this.sockets.has(socket)) {
111
+ this.sockets.set(socket, false);
112
+ if (this.held === 0) {
113
+ socket.unref();
114
+ }
115
+ }
116
+ }, { once: true });
117
+ }
118
+ outgoing.end(body);
119
+ }));
120
+ }
121
+ /**
122
+ * Keep the process alive for this endpoint's requests while `work` runs: awaiting a promise
123
+ * does not keep Node's loop alive, so a short job awaiting delivery would otherwise exit first.
124
+ * Bounded by whatever bounds `work`.
125
+ * @param work What the caller awaits.
126
+ * @returns `work`'s result.
127
+ */
128
+ async heldWhile(work) {
129
+ this.held += 1;
130
+ this.sockets.forEach((_own, socket) => socket.ref());
131
+ try {
132
+ return await work();
133
+ }
134
+ finally {
135
+ this.held -= 1;
136
+ if (this.held === 0) {
137
+ this.sockets.forEach((own, socket) => own || socket.unref());
138
+ }
139
+ }
140
+ }
141
+ /** Release the sockets. */
142
+ destroy() {
143
+ this.agent.destroy();
144
+ }
145
+ }
146
+ // localhost, ::1 or anything in 127.0.0.0/8.
147
+ function isLoopback(hostname) {
148
+ return LOOPBACK_HOSTS.has(hostname) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
149
+ }
@@ -0,0 +1,103 @@
1
+ import { type Context } from '@opentelemetry/api';
2
+ import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';
3
+ /**
4
+ * The one line a customer adds, and everything it must never do. A port of
5
+ * `python/agent-watch-otel/agent_watch_otel/processor.py`.
6
+ *
7
+ * `onEnd` runs inside the customer's `span.end()`, so:
8
+ * 1. It never touches the network. It hashes and enqueues; sending happens on a timer or an
9
+ * immediate, never on the caller's stack. A full queue drops and counts.
10
+ * 2. It never throws, and nothing it starts ever rejects unhandled: an unhandled rejection is a
11
+ * crash in Node, which is the one failure this package must not cause.
12
+ * 3. What is queued is already digests; no prompt text outlives `onEnd`'s frame.
13
+ * 4. Its own request is invisible to the customer's instrumentation, and never holds the
14
+ * process open.
15
+ */
16
+ /**
17
+ * Calls per request. The contract allows 250 (AWT-70's `RUNTIME_BATCH_MAX_CALLS`), but a call
18
+ * carries up to 512 shingle digests, about 18 KB of JSON, so 250 would be a 4.5 MB body; 100
19
+ * keeps one request under 2 MB.
20
+ */
21
+ export declare const BATCH_MAX_CALLS = 100;
22
+ /** Construction options. Every one is optional; token and endpoint fall back to the environment. */
23
+ export interface AgentWatchSpanProcessorOptions {
24
+ /** SDK token scoped `ingest:production`. Defaults to `AGENT_WATCH_TOKEN`. */
25
+ readonly token?: string;
26
+ /** Ingest base URL. Defaults to `AGENT_WATCH_ENDPOINT`. */
27
+ readonly endpoint?: string;
28
+ /** This deployment's name. Defaults to the resource's `service.name`. */
29
+ readonly instanceId?: string;
30
+ /** Calls held while the sender is behind; past it, calls are dropped and counted. A positive integer. */
31
+ readonly maxQueue?: number;
32
+ /** How long a partial batch waits. Over 0 and at most 2^31-1. */
33
+ readonly flushMillis?: number;
34
+ /** Network timeout for one POST. */
35
+ readonly timeoutMillis?: number;
36
+ /** The whole budget `shutdown()` may spend, however many times it is called. */
37
+ readonly shutdownMillis?: number;
38
+ }
39
+ /** Delivered and dropped counts, each drop under its own reason. */
40
+ export interface ProcessorStats {
41
+ readonly sent: number;
42
+ readonly droppedQueueFull: number;
43
+ readonly droppedSendFailed: number;
44
+ readonly droppedNoModel: number;
45
+ readonly droppedError: number;
46
+ }
47
+ /** Reports finished OpenInference `LLM` spans to Agent Watch as digests, beside the customer's own processors. */
48
+ export declare class AgentWatchSpanProcessor implements SpanProcessor {
49
+ private readonly ingest;
50
+ private readonly instanceId;
51
+ private readonly maxQueue;
52
+ private readonly timeoutMillis;
53
+ private readonly shutdownMillis;
54
+ private readonly counters;
55
+ private readonly timer;
56
+ private readonly inFlight;
57
+ private readonly gate;
58
+ private queue;
59
+ private draining;
60
+ private abandoned;
61
+ private stopping;
62
+ /**
63
+ * Validate configuration and start the flush timer.
64
+ * @param options Token, endpoint and tuning; see `AgentWatchSpanProcessorOptions`.
65
+ * @throws {Error} No token, no endpoint, or an endpoint the token may not be sent to in clear
66
+ * (plain http to anything but loopback). Raised here, on the application's startup path where
67
+ * an operator sees it, and never later from `onEnd`.
68
+ */
69
+ constructor(options?: AgentWatchSpanProcessorOptions);
70
+ /**
71
+ * Delivered and dropped counts.
72
+ * @returns A snapshot copy.
73
+ */
74
+ stats(): ProcessorStats;
75
+ /**
76
+ * Nothing: a call is only reportable once it has its token counts.
77
+ * @param _span The started span.
78
+ * @param _parentContext Its parent context.
79
+ */
80
+ onStart(_span: Span, _parentContext: Context): void;
81
+ /**
82
+ * Hash a finished LLM span and queue it for the sender.
83
+ * @param span The span the SDK has just ended. Anything that is not an OpenInference `LLM` span is ignored.
84
+ */
85
+ onEnd(span: ReadableSpan): void;
86
+ /**
87
+ * Send everything queued. Resolves when the queue is empty; never rejects.
88
+ * @returns When every queued call has been sent or dropped.
89
+ */
90
+ forceFlush(): Promise<void>;
91
+ /**
92
+ * Stop reading spans and give the sender a bounded moment to drain. Bounded and spent once: a
93
+ * second call returns the first call's promise. Whatever is still in flight when the budget runs
94
+ * out is aborted, so a hung socket cannot hold the process open either.
95
+ * @returns When the queue drained or the budget ran out, whichever came first.
96
+ */
97
+ shutdown(): Promise<void>;
98
+ private stop;
99
+ private drain;
100
+ private sendAll;
101
+ private send;
102
+ private count;
103
+ }
@@ -0,0 +1,220 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { diag } from '@opentelemetry/api';
3
+ import { buildCall, isLlmSpan } from './call.js';
4
+ import { RuntimeGate, installGate, uninstallGate } from './gate.js';
5
+ import { Endpoint } from './post.js';
6
+ /**
7
+ * The one line a customer adds, and everything it must never do. A port of
8
+ * `python/agent-watch-otel/agent_watch_otel/processor.py`.
9
+ *
10
+ * `onEnd` runs inside the customer's `span.end()`, so:
11
+ * 1. It never touches the network. It hashes and enqueues; sending happens on a timer or an
12
+ * immediate, never on the caller's stack. A full queue drops and counts.
13
+ * 2. It never throws, and nothing it starts ever rejects unhandled: an unhandled rejection is a
14
+ * crash in Node, which is the one failure this package must not cause.
15
+ * 3. What is queued is already digests; no prompt text outlives `onEnd`'s frame.
16
+ * 4. Its own request is invisible to the customer's instrumentation, and never holds the
17
+ * process open.
18
+ */
19
+ /**
20
+ * Calls per request. The contract allows 250 (AWT-70's `RUNTIME_BATCH_MAX_CALLS`), but a call
21
+ * carries up to 512 shingle digests, about 18 KB of JSON, so 250 would be a 4.5 MB body; 100
22
+ * keeps one request under 2 MB.
23
+ */
24
+ export const BATCH_MAX_CALLS = 100;
25
+ const CALLS_PATH = 'v1/runtime/calls';
26
+ // The worst case is every queued call carrying 512 shingles: about 18 KB of JSON each, so
27
+ // roughly 9 MB of payload (a little more as JS strings) held in the customer's heap.
28
+ const DEFAULT_MAX_QUEUE = 512;
29
+ const DEFAULT_FLUSH_MILLIS = 5000;
30
+ // setInterval and setTimeout clamp anything above this to 1 ms, which would turn a typo into a busy loop.
31
+ const MAX_TIMER_MILLIS = 2 ** 31 - 1;
32
+ const DEFAULT_TIMEOUT_MILLIS = 10_000;
33
+ // Shorter than the request timeout: an endpoint that accepts and never answers must not turn a
34
+ // tidy stop into a SIGKILL at the end of the grace period.
35
+ const DEFAULT_SHUTDOWN_MILLIS = 3000;
36
+ /** Reports finished OpenInference `LLM` spans to Agent Watch as digests, beside the customer's own processors. */
37
+ export class AgentWatchSpanProcessor {
38
+ ingest;
39
+ instanceId;
40
+ maxQueue;
41
+ timeoutMillis;
42
+ shutdownMillis;
43
+ counters = {
44
+ sent: 0, droppedQueueFull: 0, droppedSendFailed: 0, droppedNoModel: 0, droppedError: 0
45
+ };
46
+ timer;
47
+ inFlight = new Set();
48
+ gate;
49
+ queue = [];
50
+ draining = null;
51
+ abandoned = false;
52
+ stopping = null;
53
+ /**
54
+ * Validate configuration and start the flush timer.
55
+ * @param options Token, endpoint and tuning; see `AgentWatchSpanProcessorOptions`.
56
+ * @throws {Error} No token, no endpoint, or an endpoint the token may not be sent to in clear
57
+ * (plain http to anything but loopback). Raised here, on the application's startup path where
58
+ * an operator sees it, and never later from `onEnd`.
59
+ */
60
+ constructor(options = {}) {
61
+ const token = (options.token || process.env.AGENT_WATCH_TOKEN || '').trim();
62
+ const endpoint = (options.endpoint || process.env.AGENT_WATCH_ENDPOINT || '').trim();
63
+ if (token === '') {
64
+ throw new Error('AgentWatchSpanProcessor needs a token, or AGENT_WATCH_TOKEN in the environment');
65
+ }
66
+ if (endpoint === '') {
67
+ throw new Error('AgentWatchSpanProcessor needs an endpoint, or AGENT_WATCH_ENDPOINT in the environment');
68
+ }
69
+ this.ingest = new Endpoint(token, endpoint, CALLS_PATH);
70
+ this.instanceId = options.instanceId ?? '';
71
+ // A bad number falls back to its default rather than throwing: only a missing token or
72
+ // endpoint is worth refusing to start the customer's application over.
73
+ this.maxQueue = typeof options.maxQueue === 'number' && Number.isSafeInteger(options.maxQueue) && options.maxQueue > 0 ? options.maxQueue : DEFAULT_MAX_QUEUE;
74
+ this.timeoutMillis = timerMillis(options.timeoutMillis, DEFAULT_TIMEOUT_MILLIS);
75
+ this.shutdownMillis = timerMillis(options.shutdownMillis, DEFAULT_SHUTDOWN_MILLIS);
76
+ // Unref'd: this timer must never be what keeps the customer's process alive.
77
+ this.timer = setInterval(() => {
78
+ void this.drain();
79
+ }, timerMillis(options.flushMillis, DEFAULT_FLUSH_MILLIS)).unref();
80
+ // The gate (AWT-339): from here on, a request made inside an LLM span asks first.
81
+ this.gate = new RuntimeGate(token, endpoint);
82
+ installGate(this.gate);
83
+ }
84
+ /**
85
+ * Delivered and dropped counts.
86
+ * @returns A snapshot copy.
87
+ */
88
+ stats() {
89
+ return { ...this.counters };
90
+ }
91
+ /**
92
+ * Nothing: a call is only reportable once it has its token counts.
93
+ * @param _span The started span.
94
+ * @param _parentContext Its parent context.
95
+ */
96
+ onStart(_span, _parentContext) {
97
+ // Intentionally empty.
98
+ }
99
+ /**
100
+ * Hash a finished LLM span and queue it for the sender.
101
+ * @param span The span the SDK has just ended. Anything that is not an OpenInference `LLM` span is ignored.
102
+ */
103
+ onEnd(span) {
104
+ try {
105
+ if (this.stopping !== null || !isLlmSpan(span)) {
106
+ return;
107
+ }
108
+ const call = buildCall(span, this.instanceId);
109
+ if (call === null) {
110
+ this.count('droppedNoModel');
111
+ return;
112
+ }
113
+ if (this.queue.length >= this.maxQueue) {
114
+ this.count('droppedQueueFull');
115
+ return;
116
+ }
117
+ this.queue.push(call);
118
+ if (this.queue.length === BATCH_MAX_CALLS) {
119
+ // Off the span's stack. drain() is single-flight, so a second immediate is harmless.
120
+ setImmediate(() => void this.drain()).unref();
121
+ }
122
+ }
123
+ catch {
124
+ // Counted, not only logged: the stats account for every call read and never delivered.
125
+ this.count('droppedError');
126
+ diag.debug('agent-watch: span dropped');
127
+ }
128
+ }
129
+ /**
130
+ * Send everything queued. Resolves when the queue is empty; never rejects.
131
+ * @returns When every queued call has been sent or dropped.
132
+ */
133
+ forceFlush() {
134
+ return this.ingest.heldWhile(() => this.drain());
135
+ }
136
+ /**
137
+ * Stop reading spans and give the sender a bounded moment to drain. Bounded and spent once: a
138
+ * second call returns the first call's promise. Whatever is still in flight when the budget runs
139
+ * out is aborted, so a hung socket cannot hold the process open either.
140
+ * @returns When the queue drained or the budget ran out, whichever came first.
141
+ */
142
+ shutdown() {
143
+ this.stopping ??= this.ingest.heldWhile(() => this.stop());
144
+ return this.stopping;
145
+ }
146
+ async stop() {
147
+ clearInterval(this.timer);
148
+ uninstallGate(this.gate);
149
+ this.gate.close();
150
+ const budget = new AbortController();
151
+ // Ref'd: a caller awaits this, and the budget bounds it.
152
+ await Promise.race([this.drain(), sleep(this.shutdownMillis, undefined, { signal: budget.signal })]).catch(() => undefined);
153
+ budget.abort();
154
+ this.abandoned = true;
155
+ this.count('droppedSendFailed', this.queue.length);
156
+ this.queue = [];
157
+ for (const request of this.inFlight) {
158
+ request.abort();
159
+ }
160
+ this.ingest.destroy();
161
+ }
162
+ // Single flight: one batch on the wire at a time, as Python's single sender thread.
163
+ drain() {
164
+ // sendAll() catches per batch; this only stops a rejection from going unhandled.
165
+ this.draining ??= this.sendAll()
166
+ .catch(() => undefined)
167
+ .finally(() => {
168
+ this.draining = null;
169
+ });
170
+ return this.draining;
171
+ }
172
+ async sendAll() {
173
+ while (this.queue.length > 0 && !this.abandoned) {
174
+ const batch = this.queue.splice(0, BATCH_MAX_CALLS);
175
+ try {
176
+ await this.send(batch);
177
+ }
178
+ catch {
179
+ // `send` counts its own failures; reaching here means something else threw, after the
180
+ // batch left the queue. Counted as Python's sender counts it, then the next batch goes.
181
+ this.count('droppedSendFailed', batch.length);
182
+ diag.warn(`agent-watch: ${String(batch.length)} calls dropped, sender error`);
183
+ }
184
+ }
185
+ }
186
+ // A failed batch is dropped, not retried: the queue is the only buffer, and a retry would hold
187
+ // newer calls behind an endpoint that may be down for hours.
188
+ async send(batch) {
189
+ const request = new AbortController();
190
+ const timeout = setTimeout(() => request.abort(), this.timeoutMillis).unref();
191
+ this.inFlight.add(request);
192
+ try {
193
+ // The body is never read: a 400 from a strict schema names the field it refused.
194
+ const { status } = await this.ingest.postJson(JSON.stringify({ calls: batch }), { signal: request.signal });
195
+ // Anything but 2xx, a redirect included: a redirect is a misconfigured endpoint, and
196
+ // following one would carry the token elsewhere.
197
+ if (status < 200 || status > 299) {
198
+ this.count('droppedSendFailed', batch.length);
199
+ diag.warn(`agent-watch: ${String(batch.length)} calls dropped, ingest answered ${String(status)}`);
200
+ return;
201
+ }
202
+ this.count('sent', batch.length);
203
+ }
204
+ catch {
205
+ this.count('droppedSendFailed', batch.length);
206
+ diag.warn(`agent-watch: ${String(batch.length)} calls dropped, ingest unreachable`);
207
+ }
208
+ finally {
209
+ clearTimeout(timeout);
210
+ this.inFlight.delete(request);
211
+ }
212
+ }
213
+ count(name, amount = 1) {
214
+ this.counters[name] += amount;
215
+ }
216
+ }
217
+ // A timer delay in (0, 2^31-1], or the default.
218
+ function timerMillis(value, fallback) {
219
+ return typeof value === 'number' && value > 0 && value <= MAX_TIMER_MILLIS ? value : fallback;
220
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one error the runtime gate raises, and the test for it.
3
+ *
4
+ * It says nothing but the fixed sentence: no cap, amount, feature or policy reaches the process
5
+ * (FR-016). A provider SDK that wraps transport errors surfaces it as the `cause` of its own
6
+ * connection error, so the test walks the `cause` chain. Identified by a registered symbol rather
7
+ * than `instanceof`, so a second copy of this package in the dependency tree still recognises it.
8
+ */
9
+ declare const BRAND: unique symbol;
10
+ /** The whole of what a refusal says. */
11
+ export declare const REFUSAL_MESSAGE = "An Agent Watch budget refused this call.";
12
+ /** Raised instead of sending a model call that an Agent Watch budget refuses. Never retryable. */
13
+ export declare class AgentWatchRefusal extends Error {
14
+ /** A retry gets the same answer, from the held decision, without asking Agent Watch again. */
15
+ readonly retryable = false;
16
+ readonly [BRAND] = true;
17
+ /** The fixed sentence, and nothing else. */
18
+ constructor();
19
+ }
20
+ /**
21
+ * Whether an error is an Agent Watch refusal, or was caused by one.
22
+ * @param error Anything caught.
23
+ * @returns `true` when it or any error in its `cause` chain is an `AgentWatchRefusal`.
24
+ */
25
+ export declare function isAgentWatchRefusal(error: unknown): boolean;
26
+ export {};
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The one error the runtime gate raises, and the test for it.
3
+ *
4
+ * It says nothing but the fixed sentence: no cap, amount, feature or policy reaches the process
5
+ * (FR-016). A provider SDK that wraps transport errors surfaces it as the `cause` of its own
6
+ * connection error, so the test walks the `cause` chain. Identified by a registered symbol rather
7
+ * than `instanceof`, so a second copy of this package in the dependency tree still recognises it.
8
+ */
9
+ const BRAND = Symbol.for('agent-watch.otel.refusal');
10
+ // Far deeper than any SDK nests; bounds a cycle of causes.
11
+ const MAX_CAUSE_DEPTH = 16;
12
+ /** The whole of what a refusal says. */
13
+ export const REFUSAL_MESSAGE = 'An Agent Watch budget refused this call.';
14
+ /** Raised instead of sending a model call that an Agent Watch budget refuses. Never retryable. */
15
+ export class AgentWatchRefusal extends Error {
16
+ /** A retry gets the same answer, from the held decision, without asking Agent Watch again. */
17
+ retryable = false;
18
+ [BRAND] = true;
19
+ /** The fixed sentence, and nothing else. */
20
+ constructor() {
21
+ super(REFUSAL_MESSAGE);
22
+ this.name = 'AgentWatchRefusal';
23
+ }
24
+ }
25
+ /**
26
+ * Whether an error is an Agent Watch refusal, or was caused by one.
27
+ * @param error Anything caught.
28
+ * @returns `true` when it or any error in its `cause` chain is an `AgentWatchRefusal`.
29
+ */
30
+ export function isAgentWatchRefusal(error) {
31
+ let current = error;
32
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === 'object' && current !== null; depth++) {
33
+ if (current[BRAND] === true) {
34
+ return true;
35
+ }
36
+ current = current.cause;
37
+ }
38
+ return false;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@agent-watch-ai/otel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Agent Watch's OpenTelemetry span processor: production LLM spend as digests, never prompt text.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Agent-Watch-AI/agent-watch-edge.git",
9
+ "directory": "node/agent-watch-otel"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "node": "^20.19.0 || >=22.12.0"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.build.json",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "eslint src tests",
30
+ "test": "vitest run"
31
+ },
32
+ "peerDependencies": {
33
+ "@opentelemetry/api": "^1.9.0",
34
+ "@opentelemetry/sdk-trace-base": "^1.0.0 || ^2.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@opentelemetry/api": "^1.9.1",
38
+ "@opentelemetry/core": "^2.12.0",
39
+ "@opentelemetry/resources": "^2.12.0",
40
+ "@opentelemetry/sdk-trace-base": "^2.12.0"
41
+ }
42
+ }