@stigg/agent-sdk-js 0.4.0 → 0.5.1

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.
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = void 0;
4
+ /**
5
+ * The LangChain and LangGraph binding: a callback handler over an agent graph's middleware.
6
+ *
7
+ * const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
8
+ * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggCustomerId } });
9
+ *
10
+ * Everything here is LangChain's protocol - which hook a step starts on, where the customer and
11
+ * the usage are, which agent a step belongs to. Whether a step may run and what it is worth is the
12
+ * middleware's. LangChain hands a callback to every run beneath the `invoke`, sub-agents and
13
+ * LangGraph nodes included, so one handler governs the whole graph.
14
+ *
15
+ * A step belongs to the nearest run above it named for a configured agent: a `createAgent` or
16
+ * `createReactAgent` by its `name`, a hand-built graph's node by its own. That is LangChain's run
17
+ * tree, not metadata - `lc_agent_name` in metadata is inherited by an unnamed sub-agent, carried
18
+ * into a sub-agent by a tool that passes its own config, and settable by whoever sends the run's
19
+ * metadata, so it would bill the wrong agent or let a client pick whose budget pays.
20
+ *
21
+ * It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
22
+ * an async handler in the background unless `awaitHandlers` is set too, so both are set - through
23
+ * the constructor, because `awaitHandlers` is derived inside `super()`. And LangChain copies a
24
+ * handler (`new this.constructor(this)`) on its way into a nested run, so a copy shares the
25
+ * original's middleware and run tree rather than billing a sub-agent to a fresh session.
26
+ *
27
+ * The one file in `src/` that imports LangChain, reached only through the
28
+ * `@stigg/agent-sdk-js/langchain` entry.
29
+ */
30
+ const base_1 = require("@langchain/core/callbacks/base");
31
+ const hooks_1 = require("../../../core/hooks");
32
+ const token_usage_1 = require("../../shared/token-usage");
33
+ /**
34
+ * The metadata key a caller names the customer under, on `invoke(input, { metadata })`. It wins
35
+ * over the handler's default, so set it server-side - on LangGraph Platform the client sends
36
+ * `metadata`, so set it in your auth handler, never from the request.
37
+ */
38
+ exports.CUSTOMER_METADATA_KEY = 'stiggCustomerId';
39
+ /**
40
+ * What `createAgent({ name })` and LangGraph stamp in metadata. Never what a step is billed as -
41
+ * see the top of this file - only the label an unlisted agent is reported under.
42
+ */
43
+ const AGENT_METADATA_KEY = 'lc_agent_name';
44
+ const NODE_METADATA_KEY = 'langgraph_node';
45
+ /** How many runs can be open at once before the oldest is forgotten - a run whose end never came. */
46
+ const OPEN_RUNS_LIMIT = 10_000;
47
+ /** The counters LangChain's `usage_metadata` states at the top level, reported under its own names. */
48
+ const LANGCHAIN_METRICS = ['input_tokens', 'output_tokens', 'total_tokens'];
49
+ /** Where a caller puts the customer, for the refusal a step with none raises. */
50
+ const MISSING_CUSTOMER = `Name it on every run as \`invoke(input, { metadata: { ${exports.CUSTOMER_METADATA_KEY}: customerId } })\`, ` +
51
+ 'or give the handler a default in its `stiggContext: { customerId }`.';
52
+ /**
53
+ * The runs LangChain has open, each with its name and parent, so a step can be traced up to the
54
+ * agent it belongs to. Shared by a handler and every copy LangChain makes of it.
55
+ */
56
+ class RunTree {
57
+ agents;
58
+ rootAgent;
59
+ warn;
60
+ open = new Map();
61
+ warnedFull = false;
62
+ warned = new Set();
63
+ /**
64
+ * `rootAgent`, when set, is what a top-level run is, whatever it was named: the caller names that
65
+ * run (`invoke(input, { runName })`), so its name alone would let them pick whose budget pays.
66
+ * `warn` is the SDK's logger, for what is said once.
67
+ */
68
+ constructor(agents, rootAgent, warn) {
69
+ this.agents = agents;
70
+ this.rootAgent = rootAgent;
71
+ this.warn = warn;
72
+ }
73
+ start(runId, parent, name) {
74
+ this.open.set(runId, { name: parent === undefined && this.rootAgent ? this.rootAgent : name, parent });
75
+ if (this.open.size <= OPEN_RUNS_LIMIT)
76
+ return;
77
+ // A forgotten run breaks the walk for the steps beneath it: they fall back to the shared
78
+ // context - still checked and reported, but not as their agent - so that is said.
79
+ this.open.delete(this.open.keys().next().value);
80
+ if (!this.warnedFull) {
81
+ this.warnedFull = true;
82
+ this.warnOnce(`[stigg] more than ${OPEN_RUNS_LIMIT} LangChain runs are open at once, so the oldest are being ` +
83
+ 'forgotten: a step beneath one is governed by the shared context rather than as its agent.');
84
+ }
85
+ }
86
+ /** Says a thing once per process - and never fails a step for the logger's sake. */
87
+ warnOnce(message) {
88
+ if (this.warned.has(message) || this.warned.size >= 100)
89
+ return;
90
+ this.warned.add(message);
91
+ (0, hooks_1.safely)(() => this.warn(message), () => undefined);
92
+ }
93
+ end(runId) {
94
+ this.open.delete(runId);
95
+ }
96
+ /** The agent a run belongs to: itself when it is one, otherwise whatever its parent belongs to. */
97
+ agentAt(runId) {
98
+ const run = runId === undefined ? undefined : this.open.get(runId);
99
+ if (!run)
100
+ return undefined; // the top of the tree, or a run this handler never saw start
101
+ if (run.name !== undefined && this.isAgent(run.name))
102
+ return run.name;
103
+ return this.agentAt(run.parent);
104
+ }
105
+ isAgent(name) {
106
+ return this.agents.has(name);
107
+ }
108
+ }
109
+ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
110
+ name = 'StiggLangChainCallbackHandler';
111
+ /**
112
+ * The graph's middleware, which every hook below drives. Protected, for a subclass that traces:
113
+ * the handler itself exposes nothing more, because what it did is flushed through the SDK it was
114
+ * given (`stigg.flush()`), and reported through the `hooks` it was configured with.
115
+ */
116
+ middleware;
117
+ runs;
118
+ constructor(options) {
119
+ super({ raiseError: true, _awaitHandler: true });
120
+ if (options instanceof StiggLangChainCallbackHandler) {
121
+ // LangChain's own copy, on its way into a nested run: the same graph, so the same state.
122
+ this.middleware = options.middleware;
123
+ this.runs = options.runs;
124
+ return;
125
+ }
126
+ const { stigg, rootAgent, ...middlewareOptions } = options;
127
+ if (rootAgent !== undefined && !options.agents?.[rootAgent]) {
128
+ throw new Error(`\`rootAgent\` names "${rootAgent}", which \`agents\` does not list - it must be one of them.`);
129
+ }
130
+ if (!stigg || typeof stigg.middleware !== 'function') {
131
+ throw new Error('`StiggLangChainCallbackHandler` needs `stigg`: the `StiggAgentSDK` it governs through.');
132
+ }
133
+ this.middleware = stigg.middleware({ ...middlewareOptions, missingCustomer: MISSING_CUSTOMER });
134
+ this.runs = new RunTree(new Set(Object.keys(options.agents ?? {})), rootAgent, (message) => stigg.config.logger.warn(message));
135
+ }
136
+ // The declared signature puts `runType` fourth and `parentRunId` last, but the callback manager
137
+ // calls this with `parentRunId` fourth and `runName` eighth - so it is read by what is passed.
138
+ // True of `@langchain/core` 1.2; the attribution tests run against the real manager, so a release
139
+ // that changes either side fails them.
140
+ handleChainStart(...args) {
141
+ const [, , runId, parentRunId, , , , runName] = args;
142
+ this.runs.start(runId, parentRunId, runName);
143
+ }
144
+ handleChainEnd(_outputs, runId) {
145
+ this.runs.end(runId);
146
+ }
147
+ handleChainError(_err, runId) {
148
+ this.runs.end(runId);
149
+ }
150
+ handleChatModelStart(_llm, _messages, runId, parentRunId, _extraParams, _tags, metadata) {
151
+ return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.chat_model');
152
+ }
153
+ // The completion-model path, for an LLM that is not a chat model. Same gate, same meter.
154
+ handleLLMStart(_llm, _prompts, runId, parentRunId, _extraParams, _tags, metadata) {
155
+ return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.llm');
156
+ }
157
+ handleLLMEnd(output, runId) {
158
+ const result = output;
159
+ const message = result?.generations?.[0]?.[0]?.message;
160
+ const issued = (message?.tool_calls ?? []).flatMap((call) => call.id && call.name ? [{ id: call.id, name: call.name }] : []);
161
+ return this.middleware.endModel(runId, measurementOf(message) ?? completionMeasurement(result, runId), issued);
162
+ }
163
+ handleLLMError(_err, runId) {
164
+ this.middleware.abandon(runId);
165
+ }
166
+ handleToolStart(tool, input, runId, parentRunId, _tags, metadata, runName, toolCallId) {
167
+ // `runName` is set only for a tool built with `tool()`; a class-based one - `TavilySearch`,
168
+ // most packaged integrations - starts with none, and a serialized form that names only its
169
+ // class. The middleware names it from the model's own tool call, or by that class when no
170
+ // model called it.
171
+ const serialized = tool;
172
+ const name = runName ?? serialized?.name;
173
+ // A tool is a run too: a sub-agent it starts belongs below it in the tree.
174
+ this.runs.start(runId, parentRunId, name);
175
+ return this.middleware.startTool(runId, { name, callId: toolCallId, kind: serialized?.id?.at(-1), input: decoded(input) }, this.attributionOf(parentRunId, metadata));
176
+ }
177
+ handleToolEnd(output, runId) {
178
+ this.runs.end(runId);
179
+ // A tool the model called returns a `ToolMessage`, its result serialized into `content` - and
180
+ // its name, for a tool that started without one.
181
+ const message = isToolMessage(output) ? output : undefined;
182
+ return this.middleware.endTool(runId, decoded(message ? message.content : output), message?.name);
183
+ }
184
+ handleToolError(_err, runId) {
185
+ this.runs.end(runId);
186
+ this.middleware.abandon(runId);
187
+ }
188
+ /**
189
+ * Who a step is for: the configured agent above it in the run tree, and the run's customer. With
190
+ * none above it, a name from metadata is passed on only as a label for the "not listed" warning -
191
+ * never a configured agent's name, which the tree alone can confer.
192
+ */
193
+ attributionOf(parentRunId, metadata) {
194
+ const label = metadata?.[AGENT_METADATA_KEY] ?? metadata?.[NODE_METADATA_KEY];
195
+ const unlisted = typeof label === 'string' && !this.runs.isAgent(label) ? label : undefined;
196
+ const customerId = metadata?.[exports.CUSTOMER_METADATA_KEY];
197
+ const agent = this.runs.agentAt(parentRunId);
198
+ // Metadata naming a configured agent the tree does not find is a misconfiguration - a subgraph
199
+ // node named apart from its agent, a top-level agent with no `rootAgent` - or a caller's attempt.
200
+ if (agent === undefined && typeof label === 'string' && this.runs.isAgent(label)) {
201
+ this.runs.warnOnce(`[stigg] a step's metadata names agent "${label}", but no run above it is named "${label}" - it is ` +
202
+ 'governed by the shared context. Name the node or the agent after it, or set `rootAgent` if it is ' +
203
+ 'the top-level agent.');
204
+ }
205
+ return {
206
+ agent: agent ?? unlisted,
207
+ customerId: typeof customerId === 'string' && customerId ? customerId : undefined,
208
+ };
209
+ }
210
+ }
211
+ exports.StiggLangChainCallbackHandler = StiggLangChainCallbackHandler;
212
+ /** LangChain's `ToolMessage`, told by shape - the class is the application's copy, not ours. */
213
+ function isToolMessage(value) {
214
+ return (typeof value === 'object' &&
215
+ value !== null &&
216
+ value.type === 'tool' &&
217
+ 'tool_call_id' in value &&
218
+ 'content' in value);
219
+ }
220
+ /**
221
+ * A tool's input or output as its own author wrote it: LangChain hands both on serialized to JSON,
222
+ * so they are parsed back - and passed as they came when they are not JSON.
223
+ */
224
+ function decoded(value) {
225
+ if (typeof value !== 'string')
226
+ return value;
227
+ try {
228
+ return JSON.parse(value);
229
+ }
230
+ catch {
231
+ return value;
232
+ }
233
+ }
234
+ /** One generation's usage as a measurement, in LangChain's own vocabulary and the normalized one. */
235
+ function measurementOf(message) {
236
+ const usage = message?.usage_metadata;
237
+ if (!usage)
238
+ return undefined;
239
+ return {
240
+ metrics: pickNumbers(usage, LANGCHAIN_METRICS),
241
+ usage: (0, token_usage_1.normalizeUsage)(usage),
242
+ raw: usage,
243
+ // The provider's own id - `msg_…` for Anthropic - so a retried graph step that replays the same
244
+ // message is the same report.
245
+ ...(message.id ? { idempotencyKey: message.id } : {}),
246
+ };
247
+ }
248
+ /**
249
+ * A completion model's usage, which LangChain reports on `llmOutput.tokenUsage` rather than on a
250
+ * message - in camelCase, so it is mapped onto the same names a chat model reports under.
251
+ */
252
+ function completionMeasurement(result, runId) {
253
+ const usage = result?.llmOutput?.tokenUsage;
254
+ if (!usage)
255
+ return undefined;
256
+ const raw = {
257
+ input_tokens: usage.promptTokens,
258
+ output_tokens: usage.completionTokens,
259
+ total_tokens: usage.totalTokens,
260
+ };
261
+ // An empty `tokenUsage` - a streamed completion's - says nothing, and is no measurement: reported
262
+ // as zero it would look like a call that cost nothing, rather than one nobody measured.
263
+ if (!Object.values(raw).some((value) => typeof value === 'number'))
264
+ return undefined;
265
+ // No provider message id to key on, so the run's own: the same run reported twice is one report.
266
+ return { metrics: pickNumbers(raw, LANGCHAIN_METRICS), usage: (0, token_usage_1.normalizeUsage)(raw), raw, idempotencyKey: runId };
267
+ }
268
+ /** The named counters that are numbers, as they were stated. */
269
+ function pickNumbers(source, names) {
270
+ const metrics = {};
271
+ for (const name of names) {
272
+ const value = source[name];
273
+ if (typeof value === 'number' && Number.isFinite(value))
274
+ metrics[name] = value;
275
+ }
276
+ return metrics;
277
+ }
@@ -122,6 +122,14 @@ export type ResolvedContext = {
122
122
  * itself to build its own.
123
123
  */
124
124
  attributionKey?: string;
125
+ /**
126
+ * The orchestrator's own name for the agent that took this step, when a middleware governs it -
127
+ * `lead`, `researcher`. A label for hooks and logs, never sent: what Stigg attributes by is
128
+ * `agentId`, which a customer-level run does not have.
129
+ */
130
+ agent?: string;
131
+ /** The paid tool this step ran, when a middleware checked one - a label, like `agent`. */
132
+ tool?: string;
125
133
  };
126
134
  /**
127
135
  * What nobody told the SDK, and it will not guess.
@@ -137,11 +145,10 @@ export declare const CONTEXT_DEFAULTS: {
137
145
  readonly ingestMode: "async";
138
146
  };
139
147
  /**
140
- * Validates and derives, once. Everything expensive about a context happens here rather than on
141
- * the call path - see the note at the top of this file for why that split exists.
148
+ * The one field a call cannot proceed without, refused the same way from every binding. Only where
149
+ * to put the customer differs, so the remedy is the caller's to word.
142
150
  */
143
- /** The one field a call cannot proceed without, said the same way wherever it is missing. */
144
- export declare const noCustomer: () => Error;
151
+ export declare const noCustomer: (remedy?: string) => Error;
145
152
  /**
146
153
  * The client-wide policy a context falls back to before the SDK's own defaults.
147
154
  *
@@ -153,6 +160,10 @@ export type ContextDefaults = {
153
160
  checkErrorAction?: CheckErrorAction;
154
161
  ingestMode?: IngestMode;
155
162
  };
163
+ /**
164
+ * Validates and derives, once. Everything expensive about a context happens here rather than on
165
+ * the call path - see the note at the top of this file for why that split exists.
166
+ */
156
167
  export declare function resolveContext(context: StiggContext, correlationId: string, { requireCustomer, defaults }?: {
157
168
  requireCustomer?: boolean;
158
169
  defaults?: ContextDefaults;
@@ -72,15 +72,17 @@ function planId(value) {
72
72
  }
73
73
  return named;
74
74
  }
75
+ /**
76
+ * The one field a call cannot proceed without, refused the same way from every binding. Only where
77
+ * to put the customer differs, so the remedy is the caller's to word.
78
+ */
79
+ const noCustomer = (remedy = 'Give `wrap(client, { stiggContext: { customerId } })` a default for every call through ' +
80
+ 'this client, or name one per call in `stiggContext`.') => new Error(`No customerId - every governance call is scoped to a tenant. ${remedy}`);
81
+ exports.noCustomer = noCustomer;
75
82
  /**
76
83
  * Validates and derives, once. Everything expensive about a context happens here rather than on
77
84
  * the call path - see the note at the top of this file for why that split exists.
78
85
  */
79
- /** The one field a call cannot proceed without, said the same way wherever it is missing. */
80
- const noCustomer = () => new Error('No customerId - every governance call is scoped to a tenant. Give `wrap(client, ' +
81
- '{ stiggContext: { customerId } })` a default for every call through this client, or name ' +
82
- 'one per call in `stiggContext`.');
83
- exports.noCustomer = noCustomer;
84
86
  function resolveContext(context, correlationId,
85
87
  // `wrap` passes false: a client whose calls each name their own customer is legal, and its
86
88
  // types already say which calls still owe one. Every path that reaches the engine resolves it.
@@ -27,6 +27,13 @@ export type HookContext = {
27
27
  correlationId: string;
28
28
  /** What the call gated on, when it gated on anything. Undefined on a metering-only run. */
29
29
  entitlementId?: string;
30
+ /**
31
+ * The agent that took the step, by the orchestrator's own name for it, when a middleware
32
+ * governs the run. Present at the customer level too, where nothing is attributed to an agent.
33
+ */
34
+ agent?: string;
35
+ /** The tool a step ran, when it was a paid tool a middleware checked. */
36
+ tool?: string;
30
37
  };
31
38
  export type SessionHooks = {
32
39
  /** Every check this client makes. The whole decision, already formatted as `.summary`. */
@@ -54,6 +61,11 @@ export type SessionDeps = {
54
61
  logger: Logger;
55
62
  onError: (error: EngineError, context: ErrorContext) => void;
56
63
  hooks: SessionHooks;
64
+ /**
65
+ * Whether each check and report is an OpenTelemetry span. On for an orchestrator's middleware,
66
+ * where an observability tool's trace is already open around the graph; off for `wrap`.
67
+ */
68
+ tracing?: boolean;
57
69
  };
58
70
  /** What a client did, for whoever wants to print it. Spans every context it served. */
59
71
  export type SessionSummary = {
@@ -181,6 +193,8 @@ export declare class GovernanceSession {
181
193
  private warnRounded;
182
194
  private warnIfNothingDrains;
183
195
  private record;
196
+ /** Every ingest result, however it ended: to its span, then to the application's hook. */
197
+ private emitIngest;
184
198
  /**
185
199
  * Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
186
200
  * allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
@@ -189,9 +203,9 @@ export declare class GovernanceSession {
189
203
  private hook;
190
204
  /**
191
205
  * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
192
- * reached - false only for an outage, which is reported here and retried by the next call. A
193
- * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
194
- * says about a customer that does not exist.
206
+ * reached - false for a passing failure (unreachable, rate limited, timed out), which is reported
207
+ * here and retried after a short wait. A refusal is an answer: the SDK reports it once, and the
208
+ * call goes on to meet whatever the check says about a customer that does not exist.
195
209
  */
196
210
  private provisionCustomer;
197
211
  /**
@@ -29,6 +29,7 @@ const errors_1 = require("../errors");
29
29
  const headers_1 = require("../headers");
30
30
  const dimensions_1 = require("./dimensions");
31
31
  const hooks_1 = require("./hooks");
32
+ const telemetry_1 = require("./telemetry");
32
33
  const context_1 = require("./context");
33
34
  const types_1 = require("../types");
34
35
  /** What a context looks like to a hook: the attribution, and nothing a hook could change. */
@@ -37,6 +38,8 @@ const hookContext = (context) => ({
37
38
  dimensions: context.dimensions,
38
39
  correlationId: context.correlationId,
39
40
  entitlementId: context.checkedId,
41
+ ...(context.agent !== undefined ? { agent: context.agent } : {}),
42
+ ...(context.tool !== undefined ? { tool: context.tool } : {}),
40
43
  });
41
44
  /** A metric nobody can bill: refused where the mapper that invented it can still be found. */
42
45
  const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric needs a name - an empty one reports to no feature at all.');
@@ -111,13 +114,14 @@ class GovernanceSession {
111
114
  * application routes on, not an exception it recovers from.
112
115
  */
113
116
  async check(context, requestedUsage = 1) {
117
+ const startedAt = Date.now();
114
118
  this.checks += 1;
115
119
  const { entitlement } = context;
116
120
  // Asked and answered, with nobody to ask: this context named no entitlement, so there is no
117
121
  // budget to consult and no refusal that could come back. Still counted and recorded, because
118
122
  // the caller did ask and `lastDecision` is where they read what they got.
119
123
  if (!entitlement)
120
- return this.record((0, decision_1.unenforcedDecision)(), context);
124
+ return this.record((0, decision_1.unenforcedDecision)(), context, startedAt);
121
125
  try {
122
126
  const engine = await this.deps.engine();
123
127
  // Before the check, not after: a customer Stigg has never seen holds no entitlement, so their
@@ -134,16 +138,16 @@ class GovernanceSession {
134
138
  // refused - an unknown plan, an archived customer - is refused here too, rather than
135
139
  // waved through by an `allow` that exists for outages, not for configuration mistakes.
136
140
  if (!reached && result.accessDeniedReason === CUSTOMER_NOT_FOUND) {
137
- return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
141
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
138
142
  }
139
- return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context);
143
+ return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context, startedAt);
140
144
  }
141
145
  catch (err) {
142
146
  const error = asEngineError('check', err);
143
147
  this.reportError(error, 'check', context);
144
148
  // Governance is fail-closed server-side; `'deny'` mirrors that, and `'allow'` - the default -
145
149
  // is the soft limit that keeps the application up while Stigg is unreachable.
146
- return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
150
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
147
151
  }
148
152
  }
149
153
  /** Applies the context's mapper to a token measurement. The path every binding uses. */
@@ -216,6 +220,7 @@ class GovernanceSession {
216
220
  send(context, metrics, sendOptions) {
217
221
  const { dimensions, idempotencyKey, raw, event } = sendOptions;
218
222
  const mode = context.ingestMode;
223
+ const startedAt = Date.now();
219
224
  // A broken value is one broken metric, not a broken call: the others measured fine and the
220
225
  // spend they describe already happened. Dropping only the bad one reports what is knowable.
221
226
  //
@@ -260,10 +265,10 @@ class GovernanceSession {
260
265
  // distinguishable from one that never tried, and reported to `onIngest` like every result.
261
266
  if (!Object.keys(sendable).length) {
262
267
  if (rejected)
263
- return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected));
268
+ return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected, startedAt));
264
269
  this.ingested += 1;
265
270
  const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
266
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(noop, hookContext(context)));
271
+ this.emitIngest(context, noop, startedAt);
267
272
  return Promise.resolve(noop);
268
273
  }
269
274
  const promise = this.deps
@@ -311,10 +316,10 @@ class GovernanceSession {
311
316
  this.ingestFailures += 1;
312
317
  this.reportError(rejected, rejected.operation, context, idempotencyKey);
313
318
  }
314
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(landed, hookContext(context)));
319
+ this.emitIngest(context, landed, startedAt);
315
320
  return landed;
316
321
  })
317
- .catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err)))
322
+ .catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err), startedAt))
318
323
  .finally(() => {
319
324
  this.pending.delete(promise);
320
325
  });
@@ -403,11 +408,22 @@ class GovernanceSession {
403
408
  this.deps.logger.warn(`[stigg] reported ${names} but the response carried no credit balance - ${names.includes(',') ? 'these features are' : 'this feature is'} not mapped to ${checked}, so the budget this call checks ` +
404
409
  'against will never drain. Map it in Stigg, or report to the currency directly.');
405
410
  }
406
- record(decision, context) {
411
+ record(decision, context, startedAt) {
407
412
  this.decision = decision;
413
+ // Telemetry is instrumentation exactly as a hook is: it never reaches the caller.
414
+ if (this.deps.tracing) {
415
+ (0, hooks_1.safely)(() => (0, telemetry_1.traceCheck)(context, decision, startedAt), (err) => this.deps.logger.debug(`[stigg] check span failed: ${String(err)}`));
416
+ }
408
417
  this.hook('onDecision', context, () => this.deps.hooks.onDecision?.(decision, hookContext(context)));
409
418
  return decision;
410
419
  }
420
+ /** Every ingest result, however it ended: to its span, then to the application's hook. */
421
+ emitIngest(context, result, startedAt) {
422
+ if (this.deps.tracing) {
423
+ (0, hooks_1.safely)(() => (0, telemetry_1.traceIngest)(context, result, startedAt), (err) => this.deps.logger.debug(`[stigg] ingest span failed: ${String(err)}`));
424
+ }
425
+ this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(result, hookContext(context)));
426
+ }
411
427
  /**
412
428
  * Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
413
429
  * allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
@@ -418,9 +434,9 @@ class GovernanceSession {
418
434
  }
419
435
  /**
420
436
  * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
421
- * reached - false only for an outage, which is reported here and retried by the next call. A
422
- * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
423
- * says about a customer that does not exist.
437
+ * reached - false for a passing failure (unreachable, rate limited, timed out), which is reported
438
+ * here and retried after a short wait. A refusal is an answer: the SDK reports it once, and the
439
+ * call goes on to meet whatever the check says about a customer that does not exist.
424
440
  */
425
441
  async provisionCustomer(context) {
426
442
  if (!context.defaultPlanId || !context.customerId || !this.deps.provisionCustomer)
@@ -446,7 +462,7 @@ class GovernanceSession {
446
462
  customerId: context?.customerId,
447
463
  }), (err) => this.deps.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(err)}`));
448
464
  }
449
- failedIngest(context, idempotencyKey, error) {
465
+ failedIngest(context, idempotencyKey, error, startedAt = Date.now()) {
450
466
  this.ingestFailures += 1;
451
467
  // The error knows what failed - reading a vendor's usage is not the same event as the report
452
468
  // that would have followed it, and a context that says otherwise makes the two
@@ -472,7 +488,7 @@ class GovernanceSession {
472
488
  replayed: error.replayed ?? false,
473
489
  error,
474
490
  };
475
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(failure, hookContext(context)));
491
+ this.emitIngest(context, failure, startedAt);
476
492
  return failure;
477
493
  }
478
494
  }
@@ -0,0 +1,8 @@
1
+ import type { AccessDecision, IngestResult } from '../types';
2
+ import type { ResolvedContext } from './context';
3
+ /** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
4
+ export declare const TRACER_NAME = "@stigg/agent-sdk-js";
5
+ /** One check, from when it was asked to when it was answered. */
6
+ export declare function traceCheck(context: ResolvedContext, decision: AccessDecision, startedAt: number): void;
7
+ /** One report, whether it landed, failed, or had nothing to send. */
8
+ export declare function traceIngest(context: ResolvedContext, result: IngestResult, startedAt: number): void;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TRACER_NAME = void 0;
4
+ exports.traceCheck = traceCheck;
5
+ exports.traceIngest = traceIngest;
6
+ const sdk_identity_1 = require("../sdk-identity");
7
+ /** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
8
+ exports.TRACER_NAME = '@stigg/agent-sdk-js';
9
+ let api;
10
+ /** The application's copy of the API, loaded once - or `null` when it has none installed. */
11
+ function openTelemetry() {
12
+ if (api === undefined) {
13
+ try {
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ api = require('@opentelemetry/api');
16
+ }
17
+ catch {
18
+ api = null;
19
+ }
20
+ }
21
+ return api;
22
+ }
23
+ /** Asked for per span, so a provider registered after this package was loaded is still found. */
24
+ const tracer = (otel) => otel.trace.getTracer(exports.TRACER_NAME, sdk_identity_1.AGENT_SDK_VERSION);
25
+ /** Who the call was for: the attribution, as the check and the report both carry it. */
26
+ function attribution(context) {
27
+ const attributes = {
28
+ 'stigg.customer.id': context.customerId,
29
+ 'stigg.correlation_id': context.correlationId,
30
+ };
31
+ if (context.agent !== undefined)
32
+ attributes['gen_ai.agent.name'] = context.agent;
33
+ if (context.tool !== undefined)
34
+ attributes['gen_ai.tool.name'] = context.tool;
35
+ if (context.attributionKey && context.dimensions[context.attributionKey] !== undefined) {
36
+ attributes['stigg.agent.id'] = context.dimensions[context.attributionKey];
37
+ }
38
+ for (const [key, value] of Object.entries(context.dimensions))
39
+ attributes[`stigg.dimension.${key}`] = value;
40
+ if (context.eventName)
41
+ attributes['stigg.event.name'] = context.eventName.name;
42
+ return attributes;
43
+ }
44
+ /** One check, from when it was asked to when it was answered. */
45
+ function traceCheck(context, decision, startedAt) {
46
+ const otel = openTelemetry();
47
+ if (!otel)
48
+ return;
49
+ const attributes = {
50
+ ...attribution(context),
51
+ 'stigg.decision.has_access': decision.hasAccess,
52
+ 'stigg.decision.source': decision.source,
53
+ };
54
+ if (context.checkedId)
55
+ attributes['stigg.entitlement.id'] = context.checkedId;
56
+ if (decision.reason)
57
+ attributes['stigg.decision.reason'] = decision.reason;
58
+ if (decision.deniedBy)
59
+ attributes['stigg.decision.denied_by'] = decision.deniedBy;
60
+ if (decision.remaining !== undefined)
61
+ attributes['stigg.decision.remaining'] = decision.remaining;
62
+ if (decision.remedy)
63
+ attributes['stigg.decision.remedy'] = decision.remedy;
64
+ if (decision.isFallback)
65
+ attributes['stigg.decision.is_fallback'] = true;
66
+ const span = tracer(otel).startSpan('stigg.check', { startTime: startedAt, attributes });
67
+ if (!decision.hasAccess) {
68
+ span.recordException({ name: 'AccessDeniedError', message: decision.message });
69
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: decision.summary });
70
+ }
71
+ span.end();
72
+ }
73
+ /** One report, whether it landed, failed, or had nothing to send. */
74
+ function traceIngest(context, result, startedAt) {
75
+ const otel = openTelemetry();
76
+ if (!otel)
77
+ return;
78
+ const attributes = {
79
+ ...attribution(context),
80
+ 'stigg.ingest.ok': result.ok,
81
+ 'stigg.ingest.mode': result.mode,
82
+ 'stigg.ingest.idempotency_key': result.idempotencyKey,
83
+ };
84
+ for (const [metric, value] of Object.entries(result.reported))
85
+ attributes[`stigg.metric.${metric}`] = value;
86
+ if (result.creditsConsumed !== undefined)
87
+ attributes['stigg.ingest.credits_consumed'] = result.creditsConsumed;
88
+ if (result.replayed !== undefined)
89
+ attributes['stigg.ingest.replayed'] = result.replayed;
90
+ const span = tracer(otel).startSpan('stigg.ingest', { startTime: startedAt, attributes });
91
+ if (!result.ok) {
92
+ const message = result.error instanceof Error ? result.error.message : String(result.error ?? 'ingest failed');
93
+ span.recordException({ name: 'EngineError', message });
94
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message });
95
+ }
96
+ span.end();
97
+ }
@@ -94,9 +94,10 @@ export declare class NodeSdkEngine implements GovernanceEngine {
94
94
  * mutation, and writes the entitlements it answers with straight into the cache - so the check
95
95
  * that follows reads the new plan locally instead of fetching it.
96
96
  *
97
- * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
98
- * because the caller treats them differently: a refusal will be the same next time, and an
99
- * unreachable server may not.
97
+ * Resolves with a final answer - created, duplicate, or a refusal that will be the same next
98
+ * time. Throws for a passing failure - unreachable, rate limited, timed out - so the customer is
99
+ * asked again later: remembering one as a refusal would deny that customer until the process
100
+ * restarts.
100
101
  */
101
102
  provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
102
103
  upsertEntity(): Promise<void>;