@struct-ai/sdk 0.3.17 → 0.4.3
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/README.md +84 -17
- package/dist/commonjs/context.d.ts +19 -0
- package/dist/commonjs/context.js +58 -0
- package/dist/commonjs/core.js +104 -8
- package/dist/commonjs/events.d.ts +17 -6
- package/dist/commonjs/events.js +82 -59
- package/dist/commonjs/genai-content.d.ts +52 -0
- package/dist/commonjs/genai-content.js +143 -0
- package/dist/commonjs/instrument.d.ts +47 -0
- package/dist/commonjs/instrument.js +158 -0
- package/dist/commonjs/integrations/anthropic-content.js +18 -6
- package/dist/commonjs/integrations/anthropic.d.ts +6 -1
- package/dist/commonjs/integrations/anthropic.js +113 -125
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +3 -0
- package/dist/commonjs/integrations/langchain-callback.js +84 -6
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/openai-content.d.ts +34 -0
- package/dist/commonjs/integrations/openai-content.js +375 -0
- package/dist/commonjs/integrations/openai.d.ts +39 -0
- package/dist/commonjs/integrations/openai.js +305 -0
- package/dist/commonjs/semconv.d.ts +1 -0
- package/dist/commonjs/semconv.js +1 -0
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/context.d.ts +19 -0
- package/dist/esm/context.js +56 -0
- package/dist/esm/core.js +104 -8
- package/dist/esm/events.d.ts +17 -6
- package/dist/esm/events.js +82 -61
- package/dist/esm/genai-content.d.ts +52 -0
- package/dist/esm/genai-content.js +137 -0
- package/dist/esm/instrument.d.ts +47 -0
- package/dist/esm/instrument.js +155 -0
- package/dist/esm/integrations/anthropic-content.js +19 -7
- package/dist/esm/integrations/anthropic.d.ts +6 -1
- package/dist/esm/integrations/anthropic.js +112 -126
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +3 -0
- package/dist/esm/integrations/langchain-callback.js +85 -7
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/openai-content.d.ts +34 -0
- package/dist/esm/integrations/openai-content.js +360 -0
- package/dist/esm/integrations/openai.d.ts +39 -0
- package/dist/esm/integrations/openai.js +296 -0
- package/dist/esm/semconv.d.ts +1 -0
- package/dist/esm/semconv.js +1 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @struct-ai/sdk
|
|
2
2
|
|
|
3
3
|
Struct agent observability SDK for TypeScript/Node.js. Auto-instruments AI agent
|
|
4
|
-
frameworks — the Anthropic SDK
|
|
5
|
-
traces + logs to
|
|
4
|
+
frameworks and LLM SDKs — the Anthropic SDK, the OpenAI SDK (Responses API),
|
|
5
|
+
and LangChain.js — and emits OpenTelemetry traces + logs to
|
|
6
|
+
[struct.ai](https://struct.ai) with zero config.
|
|
6
7
|
|
|
7
8
|
This is the TypeScript port of [`struct-sdk`](https://pypi.org/project/struct-sdk/)
|
|
8
9
|
(Python). Span names, attribute keys, and log event shapes are identical across
|
|
@@ -13,7 +14,7 @@ the two SDKs so the server processes both uniformly.
|
|
|
13
14
|
```bash
|
|
14
15
|
npm install @struct-ai/sdk
|
|
15
16
|
# optional — the SDK auto-instruments these if present
|
|
16
|
-
npm install @anthropic-ai/sdk @langchain/core @langchain/langgraph
|
|
17
|
+
npm install @anthropic-ai/sdk openai @langchain/core @langchain/langgraph
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
Requires Node 18+.
|
|
@@ -57,6 +58,7 @@ await struct.agent({ name: "checkout" }, async () => {
|
|
|
57
58
|
|---|---|---|---|
|
|
58
59
|
| `@anthropic-ai/sdk` | `Messages.prototype.create`, `.stream` | `chat {model}` | Cache-token accounting, streaming with tool-use reconstruction. Defers to a LangChain `BaseChatModel` call already in progress (e.g. `ChatAnthropic`) so the two integrations don't double-emit — see ownership order below |
|
|
59
60
|
| `@anthropic-ai/bedrock-sdk`, `@anthropic-ai/vertex-sdk` | `Messages.prototype.*` | `chat {model}` | Best-effort, if installed |
|
|
61
|
+
| `openai` | `Responses.prototype.create` | `chat {model}` | **Responses API, non-streaming calls only** — `responses.create({stream: true})` and `responses.stream()` pass through untouched (no span), and `chat.completions` is not instrumented. Cache-read/cache-write/reasoning token accounting; `function_call` → `struct.tool()` id auto-linkage. Requires an openai release with the Responses API (v4 releases without it are a graceful no-op). Defers to a LangChain call in progress, same as Anthropic |
|
|
60
62
|
| `@langchain/core` `BaseChatModel` | `.invoke`, `.stream` | `chat {model}` | Always owns the `chat` span for `ChatAnthropic`/etc. calls, even when a provider-direct instrumentor (e.g. the Anthropic patch) is also active — the framework layer outranks the provider layer (single span; see [AGENTS.md](./AGENTS.md) §5) |
|
|
61
63
|
| `@langchain/core` `StructuredTool` | `.invoke` | `execute_tool {name}` | Extracts `tool_call_id` from LangChain ToolCall input or pending queue |
|
|
62
64
|
| `@langchain/core` `BaseRetriever` | `.invoke` | `retrieval {name}` | |
|
|
@@ -166,6 +168,37 @@ await struct.agent({ name: "checkout" }, async () => {
|
|
|
166
168
|
`@anthropic-ai/sdk`, `@anthropic-ai/bedrock-sdk`, and `@anthropic-ai/vertex-sdk`
|
|
167
169
|
are all auto-instrumented for chat spans.
|
|
168
170
|
|
|
171
|
+
#### OpenAI SDK (raw, Responses API)
|
|
172
|
+
|
|
173
|
+
Same rule as raw Anthropic — chat spans emit automatically; wrap your agent
|
|
174
|
+
loop and tools yourself:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { struct } from "@struct-ai/sdk";
|
|
178
|
+
struct.init({ ingestKey: "pk-...", serviceName: "support-agent" });
|
|
179
|
+
|
|
180
|
+
import OpenAI from "openai";
|
|
181
|
+
const client = new OpenAI();
|
|
182
|
+
|
|
183
|
+
await struct.agent({ name: "support" }, async () => {
|
|
184
|
+
const resp = await client.responses.create({
|
|
185
|
+
model: "gpt-5.5",
|
|
186
|
+
input: "triage this ticket",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// tool_call_id is auto-filled from the preceding response's function_call.
|
|
190
|
+
await struct.tool({ name: "lookup_order" }, async () => {
|
|
191
|
+
return await lookupOrder(resp);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Scope:** the OpenAI integration covers **non-streaming `responses.create()`**
|
|
197
|
+
only. Streaming calls (`stream: true` / `responses.stream()`) run unmodified and
|
|
198
|
+
emit no span, and `chat.completions` is not instrumented. Azure OpenAI clients
|
|
199
|
+
(`AzureOpenAI`) share the same `Responses` class and are covered by the same
|
|
200
|
+
patch.
|
|
201
|
+
|
|
169
202
|
#### LangChain `BaseChatModel` (no agent/graph)
|
|
170
203
|
|
|
171
204
|
If you call `ChatAnthropic.invoke(...)` (or any other `BaseChatModel`)
|
|
@@ -206,13 +239,13 @@ struct.init({
|
|
|
206
239
|
```
|
|
207
240
|
|
|
208
241
|
- **`EventOnly`** (default): per-message content lands on OTel log records
|
|
209
|
-
(`gen_ai.{user,assistant,system,tool}.message`, `gen_ai.choice`).
|
|
210
|
-
metadata only.
|
|
242
|
+
(`gen_ai.{user,assistant,system,tool}.message`, `gen_ai.choice`).
|
|
211
243
|
- **`SpanOnly`**: content on span attributes (`gen_ai.input.messages`,
|
|
212
244
|
`gen_ai.output.messages`).
|
|
213
245
|
- **`SpanAndEvent`**: both.
|
|
214
|
-
- **`None`**: no content captured
|
|
215
|
-
and other metadata
|
|
246
|
+
- **`None`**: no content captured, anywhere — no log events, no span content
|
|
247
|
+
attributes. Token counts, tool call IDs, finish reasons, and other metadata
|
|
248
|
+
still flow.
|
|
216
249
|
|
|
217
250
|
Set `captureContent: false` for the legacy bool API (equivalent to
|
|
218
251
|
`ContentCaptureMode.None`).
|
|
@@ -252,7 +285,7 @@ the troubleshooting section below for the workaround.
|
|
|
252
285
|
Emits attributes per the OTel GenAI semantic conventions:
|
|
253
286
|
|
|
254
287
|
- `gen_ai.operation.name` — `chat`, `execute_tool`, `invoke_agent`, `retrieval`
|
|
255
|
-
- `gen_ai.provider.name` — `anthropic`, `openai`, `
|
|
288
|
+
- `gen_ai.provider.name` — the GenAI provider on inference spans (`anthropic`, `openai`, …); platform-routed calls report the platform value (`aws.bedrock`, `gcp.vertex_ai`, `azure.ai.openai`) when detectable from the client. `invoke_agent` spans inherit the provider from their first child inference call (omitted when no model is reached); `execute_tool`/`retrieval` spans carry no provider.
|
|
256
289
|
- `gen_ai.request.{model, max_tokens, temperature, top_p, top_k, stop_sequences}`
|
|
257
290
|
- `gen_ai.response.{model, id, finish_reasons}`
|
|
258
291
|
- `gen_ai.usage.{input_tokens, output_tokens, cache_read.input_tokens, cache_creation.input_tokens}`
|
|
@@ -260,6 +293,28 @@ Emits attributes per the OTel GenAI semantic conventions:
|
|
|
260
293
|
- `gen_ai.tool.{name, call.id, call.arguments, call.result}`
|
|
261
294
|
- `error.type` + `StatusCode.ERROR` on failures
|
|
262
295
|
|
|
296
|
+
### `error.type` values emitted
|
|
297
|
+
|
|
298
|
+
The OTel conventions ask instrumentations to document the error values they
|
|
299
|
+
report ("Instrumentations SHOULD document the list of errors they report").
|
|
300
|
+
This SDK emits exactly two kinds of value, both alongside span
|
|
301
|
+
`StatusCode.ERROR`:
|
|
302
|
+
|
|
303
|
+
| Value | When | Meaning |
|
|
304
|
+
| --- | --- | --- |
|
|
305
|
+
| exception class name (e.g. `TypeError`, `APIConnectionError`) | the instrumented call threw | We failed to execute the request. Also records an OTel exception event. |
|
|
306
|
+
| `tool_error` | an `execute_tool` span whose result signalled failure **in band** — Anthropic `tool_result` blocks with `is_error: true`, MCP `CallToolResult.isError`, or a LangChain `ToolMessage` with `status: "error"` | The tool ran and reported a failure back to the model (bad arguments, a domain "no"), so the model can self-correct. No exception object exists, so no class name is available. |
|
|
307
|
+
|
|
308
|
+
`tool_error` is a deliberate low-cardinality sentinel, which the `error.type`
|
|
309
|
+
convention explicitly permits ("another low-cardinality error identifier"; a
|
|
310
|
+
custom value MAY be used where no well-known one applies). The same split is
|
|
311
|
+
used by OpenTelemetry's MCP instrumentation in OpenLLMetry, which likewise
|
|
312
|
+
reports `error.type="tool_error"` for the `isError` path and the exception
|
|
313
|
+
class name otherwise. Matches the Python SDK.
|
|
314
|
+
|
|
315
|
+
The distinction is what lets a monitor page on genuine execution failures
|
|
316
|
+
while excluding failures the model already saw and can recover from.
|
|
317
|
+
|
|
263
318
|
Note: `gen_ai.usage.input_tokens` for Anthropic is the TRUE total — we add back
|
|
264
319
|
`cache_read_input_tokens + cache_creation_input_tokens` (which Anthropic's raw
|
|
265
320
|
response excludes). Matches the Python SDK.
|
|
@@ -270,21 +325,33 @@ response excludes). Matches the Python SDK.
|
|
|
270
325
|
`test/live/**`.
|
|
271
326
|
- `pnpm typecheck` — `tsc --noEmit`.
|
|
272
327
|
- `pnpm build` — `tshy`, producing the dual ESM/CJS `dist/`.
|
|
273
|
-
- `pnpm test:live` — the live real-model suite: real
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
328
|
+
- `pnpm test:live` — the live real-model suite: real provider SDKs
|
|
329
|
+
(`@anthropic-ai/sdk`, `openai`) and frameworks (`@langchain/*`) against the
|
|
330
|
+
real Anthropic / OpenAI APIs, verifying the emitted spans + log events
|
|
331
|
+
**in-memory** (no ingester needed). Each gated block skips cleanly (exit 0)
|
|
332
|
+
when its API key is absent — safe to leave in normal CI.
|
|
277
333
|
|
|
278
|
-
To
|
|
279
|
-
`ANTHROPIC_API_KEY=...`
|
|
280
|
-
The path is never committed
|
|
334
|
+
To run it, point `STRUCT_LIVE_ENV_FILE` at a file with the keys you want to
|
|
335
|
+
exercise (`ANTHROPIC_API_KEY=...` and/or `OPENAI_API_KEY=...`; dotenv-style
|
|
336
|
+
`KEY=value` lines, quotes optional). The path is never committed — it's
|
|
337
|
+
supplied per-invocation:
|
|
281
338
|
|
|
282
339
|
```bash
|
|
283
340
|
STRUCT_LIVE_ENV_FILE=/path/to/your/.env pnpm test:live
|
|
284
341
|
```
|
|
285
342
|
|
|
286
|
-
|
|
287
|
-
|
|
343
|
+
Only blocks whose key is present run, so a file with both keys runs both the
|
|
344
|
+
Anthropic and OpenAI suites (a handful of small real calls each — cheap model
|
|
345
|
+
aliases, small token budgets).
|
|
346
|
+
|
|
347
|
+
**Full-pipeline e2e** (`test/live/openai-ingest-e2e.live.test.ts`)
|
|
348
|
+
additionally ships the emitted telemetry to a *real* ingest endpoint and
|
|
349
|
+
flushes it, so you can confirm a change travels the whole path
|
|
350
|
+
(SDK → OTLP export → your Struct instance). It's gated on `STRUCT_INGEST_URL`
|
|
351
|
+
+ `STRUCT_INGEST_KEY` (on top of `OPENAI_API_KEY`), so it stays skipped unless
|
|
352
|
+
you deliberately supply real ingest credentials. It still self-asserts the
|
|
353
|
+
span shape in-memory; confirming the span actually *landed* means querying
|
|
354
|
+
your Struct instance's telemetry — see [AGENTS.md](./AGENTS.md).
|
|
288
355
|
- `pnpm test:parity` — the cross-language conformance harness: drives the
|
|
289
356
|
same canonical agent topology through both this SDK and `struct-sdk-python`
|
|
290
357
|
and diffs their normalized span shapes, failing (non-zero exit) on any
|
|
@@ -53,4 +53,23 @@ export declare function snapshotStore(): StructContext | undefined;
|
|
|
53
53
|
export declare function runWithStore<T>(store: StructContext | undefined, fn: () => T): T;
|
|
54
54
|
/** Test-only: run `fn` inside a completely fresh context (no parent store). */
|
|
55
55
|
export declare function runInFreshContext<T>(fn: () => T): T;
|
|
56
|
+
/**
|
|
57
|
+
* Write-once `gen_ai.provider.name` on an invoke_agent span.
|
|
58
|
+
*
|
|
59
|
+
* CONTRACT: `agentSpan` is always an SDK-OWNED span — created by our own
|
|
60
|
+
* tracer in `struct.agent()` or the LangChain handler and delivered via the
|
|
61
|
+
* ALS store / run map, which nothing else writes. Never a host object. So
|
|
62
|
+
* the industry-standard owned-object pattern applies (state lives ON the
|
|
63
|
+
* object — Sentry/dd-trace private span fields, OTel JS symbol markers): a
|
|
64
|
+
* private Symbol sentinel set after a successful write. No registries or
|
|
65
|
+
* lifecycle bookkeeping — those are for FOREIGN objects.
|
|
66
|
+
*
|
|
67
|
+
* Semantics: "a real child provider" — racing children with different
|
|
68
|
+
* providers may pick either; the sentinel is set only after a successful
|
|
69
|
+
* write so a transient failure can be retried. Parity: python
|
|
70
|
+
* `stamp_provider_once`.
|
|
71
|
+
*/
|
|
72
|
+
export declare function stampProviderOnce(agentSpan: Span | undefined, provider: string | undefined): void;
|
|
73
|
+
/** Stamp the ambient agent span with the child call's provider (write-once). */
|
|
74
|
+
export declare function propagateProviderToParent(provider: string | undefined): void;
|
|
56
75
|
//# sourceMappingURL=context.d.ts.map
|
package/dist/commonjs/context.js
CHANGED
|
@@ -14,6 +14,8 @@ exports.pushPendingToolCalls = pushPendingToolCalls;
|
|
|
14
14
|
exports.snapshotStore = snapshotStore;
|
|
15
15
|
exports.runWithStore = runWithStore;
|
|
16
16
|
exports.runInFreshContext = runInFreshContext;
|
|
17
|
+
exports.stampProviderOnce = stampProviderOnce;
|
|
18
|
+
exports.propagateProviderToParent = propagateProviderToParent;
|
|
17
19
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
18
20
|
const als = new node_async_hooks_1.AsyncLocalStorage();
|
|
19
21
|
function getStore() {
|
|
@@ -110,4 +112,60 @@ function runWithStore(store, fn) {
|
|
|
110
112
|
function runInFreshContext(fn) {
|
|
111
113
|
return als.run({}, fn);
|
|
112
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Write-once `gen_ai.provider.name` on an invoke_agent span.
|
|
117
|
+
*
|
|
118
|
+
* The GenAI spec puts `gen_ai.provider.name` on invoke_agent spans, but the
|
|
119
|
+
* layer that CREATES those spans (`struct.agent()`, the LangChain handler) is
|
|
120
|
+
* provider-agnostic and can't know the value yet — only the child inference
|
|
121
|
+
* call can. The first chat call within the agent scope stamps it (best
|
|
122
|
+
* knowledge; first one wins); an agent that never reaches a model omits the
|
|
123
|
+
* attribute rather than carrying a framework name. Parity: python
|
|
124
|
+
* `_genai_content.stamp_provider_once`.
|
|
125
|
+
*/
|
|
126
|
+
const PROVIDER_STAMPED = Symbol("struct.providerStamped");
|
|
127
|
+
/**
|
|
128
|
+
* Write-once `gen_ai.provider.name` on an invoke_agent span.
|
|
129
|
+
*
|
|
130
|
+
* CONTRACT: `agentSpan` is always an SDK-OWNED span — created by our own
|
|
131
|
+
* tracer in `struct.agent()` or the LangChain handler and delivered via the
|
|
132
|
+
* ALS store / run map, which nothing else writes. Never a host object. So
|
|
133
|
+
* the industry-standard owned-object pattern applies (state lives ON the
|
|
134
|
+
* object — Sentry/dd-trace private span fields, OTel JS symbol markers): a
|
|
135
|
+
* private Symbol sentinel set after a successful write. No registries or
|
|
136
|
+
* lifecycle bookkeeping — those are for FOREIGN objects.
|
|
137
|
+
*
|
|
138
|
+
* Semantics: "a real child provider" — racing children with different
|
|
139
|
+
* providers may pick either; the sentinel is set only after a successful
|
|
140
|
+
* write so a transient failure can be retried. Parity: python
|
|
141
|
+
* `stamp_provider_once`.
|
|
142
|
+
*/
|
|
143
|
+
function stampProviderOnce(agentSpan, provider) {
|
|
144
|
+
try {
|
|
145
|
+
if (!agentSpan || !provider)
|
|
146
|
+
return;
|
|
147
|
+
const marked = agentSpan;
|
|
148
|
+
if (marked[PROVIDER_STAMPED])
|
|
149
|
+
return;
|
|
150
|
+
agentSpan.setAttribute("gen_ai.provider.name", provider);
|
|
151
|
+
try {
|
|
152
|
+
marked[PROVIDER_STAMPED] = true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* frozen span — worst case a later child re-stamps a real provider */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* never fail the application for telemetry */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Stamp the ambient agent span with the child call's provider (write-once). */
|
|
163
|
+
function propagateProviderToParent(provider) {
|
|
164
|
+
try {
|
|
165
|
+
stampProviderOnce(getAgentSpan(), provider);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* never fail the application for telemetry */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
113
171
|
//# sourceMappingURL=context.js.map
|
package/dist/commonjs/core.js
CHANGED
|
@@ -88,12 +88,22 @@ function safe(fn, site, logger) {
|
|
|
88
88
|
fn();
|
|
89
89
|
}
|
|
90
90
|
catch (err) {
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// The diagnostic log MUST NOT itself throw into the host: the default
|
|
92
|
+
// logger reaches `console.warn`, which a host may have replaced with a
|
|
93
|
+
// throwing implementation, and callers pass custom loggers. A throw here
|
|
94
|
+
// would escape `safe()` and defeat its whole purpose (e.g. block the host
|
|
95
|
+
// call when span creation fails). Guard the logging too.
|
|
96
|
+
try {
|
|
97
|
+
if (exports.firstFailureLogged.has(site)) {
|
|
98
|
+
logger.debug(`Struct SDK suppressed exception at ${site}`, err);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
exports.firstFailureLogged.add(site);
|
|
102
|
+
logger.warn(`Struct SDK suppressed exception at ${site}`, err);
|
|
103
|
+
}
|
|
93
104
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
logger.warn(`Struct SDK suppressed exception at ${site}`, err);
|
|
105
|
+
catch {
|
|
106
|
+
/* diagnostic logging failed — never propagate into the host path */
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
}
|
|
@@ -384,7 +394,10 @@ class StructSDK {
|
|
|
384
394
|
const startedSpan = span;
|
|
385
395
|
safe(() => {
|
|
386
396
|
startedSpan.setAttribute(semconv_js_1.GEN_AI.OPERATION_NAME, "invoke_agent");
|
|
387
|
-
|
|
397
|
+
// gen_ai.provider.name is NOT set here: this layer is
|
|
398
|
+
// provider-agnostic, and a framework name ("struct") isn't a
|
|
399
|
+
// provider. The first child inference call stamps the real one via
|
|
400
|
+
// propagateProviderToParent (write-once, best knowledge).
|
|
388
401
|
startedSpan.setAttribute(semconv_js_1.GEN_AI.AGENT_NAME, agentName);
|
|
389
402
|
// gen_ai.agent.id is the stable identifier of the agent
|
|
390
403
|
// DEFINITION. Only set it when the caller provides one — we do
|
|
@@ -479,7 +492,8 @@ class StructSDK {
|
|
|
479
492
|
const startedSpan = span;
|
|
480
493
|
safe(() => {
|
|
481
494
|
startedSpan.setAttribute(semconv_js_1.GEN_AI.OPERATION_NAME, "execute_tool");
|
|
482
|
-
|
|
495
|
+
// No gen_ai.provider.name: the spec's execute_tool span does not
|
|
496
|
+
// define that attribute.
|
|
483
497
|
startedSpan.setAttribute(semconv_js_1.GEN_AI.TOOL_NAME, toolName);
|
|
484
498
|
if (toolCallId) {
|
|
485
499
|
startedSpan.setAttribute(semconv_js_1.GEN_AI.TOOL_CALL_ID, toolCallId);
|
|
@@ -495,7 +509,28 @@ class StructSDK {
|
|
|
495
509
|
if (this.captureContent && result !== undefined && result !== null) {
|
|
496
510
|
safe(() => startedSpan.setAttribute(semconv_js_1.GEN_AI.TOOL_CALL_RESULT, (0, truncation_js_1.safeJsonStringify)(result).slice(0, 8192)), "tool.set_result_attr", this._internalLogger);
|
|
497
511
|
}
|
|
498
|
-
|
|
512
|
+
if (toolResultSignalsError(result)) {
|
|
513
|
+
safe(() => {
|
|
514
|
+
startedSpan.setAttribute(semconv_js_1.ERROR_TYPE, "tool_error");
|
|
515
|
+
// The status message is content placed on a SPAN — it follows
|
|
516
|
+
// the span-content routing gate (emitSpanContent), not merely
|
|
517
|
+
// "any capture on": under EventOnly (the default) content
|
|
518
|
+
// routes to log events, so span text gets the fixed literal.
|
|
519
|
+
// Deliberate boundary: gen_ai.tool.call.result above stays on
|
|
520
|
+
// captureContent — tool spans have no log-event equivalent for
|
|
521
|
+
// results, so the attribute is the sanctioned tool-content
|
|
522
|
+
// channel in every non-None mode. Mirrors python _note_result.
|
|
523
|
+
startedSpan.setStatus({
|
|
524
|
+
code: api_1.SpanStatusCode.ERROR,
|
|
525
|
+
message: this.emitSpanContent
|
|
526
|
+
? toolErrorStatusMessage(result)
|
|
527
|
+
: "tool returned is_error=true",
|
|
528
|
+
});
|
|
529
|
+
}, "tool.set_tool_error_status", this._internalLogger);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
safe(() => startedSpan.setStatus({ code: api_1.SpanStatusCode.OK }), "tool.set_ok_status", this._internalLogger);
|
|
533
|
+
}
|
|
499
534
|
return result;
|
|
500
535
|
}
|
|
501
536
|
catch (err) {
|
|
@@ -517,4 +552,65 @@ function recordError(span, err) {
|
|
|
517
552
|
span.recordException(err);
|
|
518
553
|
}
|
|
519
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Read one property from untrusted host data, isolating the read in its
|
|
557
|
+
* own try — a hostile getter/Proxy trap on ONE key must never throw into
|
|
558
|
+
* tool()'s try block (rejecting the host's successful call) nor mask a
|
|
559
|
+
* readable value on a SIBLING key (callers probe each supported alias
|
|
560
|
+
* independently). Mirrors python `_safe_probe` — keep in lockstep.
|
|
561
|
+
*/
|
|
562
|
+
function safeProbe(obj, key) {
|
|
563
|
+
if (typeof obj !== "object" || obj === null)
|
|
564
|
+
return undefined;
|
|
565
|
+
try {
|
|
566
|
+
return obj[key];
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Whether a tool's RETURN VALUE signals in-band failure (MCP
|
|
574
|
+
* CallToolResult.isError / Anthropic tool_result.is_error). Strictly
|
|
575
|
+
* boolean `true` on the top-level object — truthy strings/numbers and
|
|
576
|
+
* nested flags do not trigger (host data is untrusted; be conservative).
|
|
577
|
+
* Each alias is probed independently so a hostile getter on one cannot
|
|
578
|
+
* mask the other. Mirrors python `_tool_result_signals_error`.
|
|
579
|
+
*/
|
|
580
|
+
function toolResultSignalsError(result) {
|
|
581
|
+
return (safeProbe(result, "isError") === true ||
|
|
582
|
+
safeProbe(result, "is_error") === true);
|
|
583
|
+
}
|
|
584
|
+
/** Short status message from an error result's content, else a fixed one.
|
|
585
|
+
*
|
|
586
|
+
* TOTAL FUNCTION: never throws and does bounded work. It runs inside the
|
|
587
|
+
* safe() closure that also sets span status — a hostile Proxy whose
|
|
588
|
+
* `length`/index traps throw must not abort that closure (which would
|
|
589
|
+
* leave the span UNSET instead of ERROR). Two layers: per-key safeProbe
|
|
590
|
+
* isolation (one hostile item cannot mask a later readable one) INSIDE a
|
|
591
|
+
* whole-body catch (collection machinery itself is untrusted), index
|
|
592
|
+
* loop instead of for..of (iterator protocol is trappable), traversal
|
|
593
|
+
* capped at 20 items. Mirrors python `_tool_error_status_message`. */
|
|
594
|
+
function toolErrorStatusMessage(result) {
|
|
595
|
+
try {
|
|
596
|
+
const content = safeProbe(result, "content");
|
|
597
|
+
if (typeof content === "string" && content)
|
|
598
|
+
return content.slice(0, 256);
|
|
599
|
+
if (Array.isArray(content)) {
|
|
600
|
+
const len = Math.min(content.length, 20);
|
|
601
|
+
for (let i = 0; i < len; i++) {
|
|
602
|
+
const item = safeProbe(content, String(i));
|
|
603
|
+
const text = safeProbe(item, "text");
|
|
604
|
+
// Truthiness (not just typeof) matches the python twin: empty-string
|
|
605
|
+
// text items are skipped, falling through to the fixed message.
|
|
606
|
+
if (typeof text === "string" && text)
|
|
607
|
+
return text.slice(0, 256);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Hostile collection — fall through to the fixed message.
|
|
613
|
+
}
|
|
614
|
+
return "tool returned is_error=true";
|
|
615
|
+
}
|
|
520
616
|
//# sourceMappingURL=core.js.map
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
|
+
import type { Span } from "@opentelemetry/api";
|
|
2
3
|
/**
|
|
3
4
|
* Emit per-message log events for an Anthropic messages.create() call.
|
|
4
|
-
*
|
|
5
|
+
* Delegates the LogRecord wiring to the shared genai-content emitters; this
|
|
6
|
+
* function is only the Anthropic message → parts mapping + ordering.
|
|
5
7
|
*/
|
|
6
|
-
export declare function emitAnthropicMessageEvents(logger: Logger, messages: unknown, system: unknown): void;
|
|
8
|
+
export declare function emitAnthropicMessageEvents(logger: Logger, messages: unknown, system: unknown, span?: Span, provider?: string): void;
|
|
9
|
+
/** Emit a gen_ai.choice LogRecord for an Anthropic response. */
|
|
10
|
+
export declare function emitAnthropicChoiceEvent(logger: Logger, contentBlocks: unknown, stopReason: string | null | undefined, span?: Span, provider?: string): void;
|
|
7
11
|
/**
|
|
8
|
-
* Emit
|
|
9
|
-
*
|
|
12
|
+
* Emit per-message log events for an OpenAI responses.create() call.
|
|
13
|
+
* `instructions` (the Responses system prompt) is emitted FIRST at index 0.
|
|
14
|
+
* Delegates LogRecord wiring to the shared emitters; only the Responses
|
|
15
|
+
* item → event mapping + ordering lives here.
|
|
10
16
|
*/
|
|
11
|
-
export declare function
|
|
17
|
+
export declare function emitOpenAIInputMessageEvents(logger: Logger, input: unknown, instructions: unknown, span?: Span, provider?: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Emit the gen_ai.choice LogRecord from an OpenAI response.output.
|
|
20
|
+
* `finishReason` is mapped inside this emitter using mapChoiceFinishReason.
|
|
21
|
+
*/
|
|
22
|
+
export declare function emitOpenAIChoiceEvent(logger: Logger, output: unknown, finishReason: string | undefined, span?: Span, provider?: string): void;
|
|
12
23
|
//# sourceMappingURL=events.d.ts.map
|
package/dist/commonjs/events.js
CHANGED
|
@@ -2,40 +2,18 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.emitAnthropicMessageEvents = emitAnthropicMessageEvents;
|
|
4
4
|
exports.emitAnthropicChoiceEvent = emitAnthropicChoiceEvent;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
5
|
+
exports.emitOpenAIInputMessageEvents = emitOpenAIInputMessageEvents;
|
|
6
|
+
exports.emitOpenAIChoiceEvent = emitOpenAIChoiceEvent;
|
|
7
|
+
const genai_content_js_1 = require("./genai-content.js");
|
|
8
8
|
const semconv_js_1 = require("./semconv.js");
|
|
9
|
-
const truncation_js_1 = require("./truncation.js");
|
|
10
9
|
const anthropic_content_js_1 = require("./integrations/anthropic-content.js");
|
|
11
|
-
|
|
12
|
-
* Emit a LogRecord with the active span context linked.
|
|
13
|
-
*
|
|
14
|
-
* Follows the OTel logs data model convention:
|
|
15
|
-
* - `body` (log record body) = event tag string (human-readable signal)
|
|
16
|
-
* - `attributes.body` (log record attribute) = JSON-serialised payload
|
|
17
|
-
*/
|
|
18
|
-
function emitLogRecord({ logger, eventName, payload, extraAttrs = {}, }) {
|
|
19
|
-
const sessionId = (0, context_js_1.getSessionId)();
|
|
20
|
-
const attributes = {
|
|
21
|
-
[semconv_js_1.EVENT_NAME]: eventName,
|
|
22
|
-
body: payload,
|
|
23
|
-
...extraAttrs,
|
|
24
|
-
};
|
|
25
|
-
if (sessionId)
|
|
26
|
-
attributes[semconv_js_1.GEN_AI.CONVERSATION_ID] = sessionId;
|
|
27
|
-
logger.emit({
|
|
28
|
-
body: eventName,
|
|
29
|
-
severityNumber: api_logs_1.SeverityNumber.INFO,
|
|
30
|
-
attributes,
|
|
31
|
-
context: api_1.context.active(),
|
|
32
|
-
});
|
|
33
|
-
}
|
|
10
|
+
const openai_content_js_1 = require("./integrations/openai-content.js");
|
|
34
11
|
/**
|
|
35
12
|
* Emit per-message log events for an Anthropic messages.create() call.
|
|
36
|
-
*
|
|
13
|
+
* Delegates the LogRecord wiring to the shared genai-content emitters; this
|
|
14
|
+
* function is only the Anthropic message → parts mapping + ordering.
|
|
37
15
|
*/
|
|
38
|
-
function emitAnthropicMessageEvents(logger, messages, system) {
|
|
16
|
+
function emitAnthropicMessageEvents(logger, messages, system, span, provider = "anthropic") {
|
|
39
17
|
if (!Array.isArray(messages))
|
|
40
18
|
return;
|
|
41
19
|
let msgIndex = 0;
|
|
@@ -45,17 +23,14 @@ function emitAnthropicMessageEvents(logger, messages, system) {
|
|
|
45
23
|
: Array.isArray(system)
|
|
46
24
|
? (0, anthropic_content_js_1.contentToParts)(system)
|
|
47
25
|
: [{ type: "text", content: String(system) }];
|
|
48
|
-
|
|
26
|
+
(0, genai_content_js_1.emitMessageEvent)({
|
|
49
27
|
logger,
|
|
28
|
+
role: "system",
|
|
29
|
+
parts,
|
|
50
30
|
eventName: semconv_js_1.EVENT_NAMES.SYSTEM_MESSAGE,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}),
|
|
55
|
-
extraAttrs: {
|
|
56
|
-
[semconv_js_1.GEN_AI.PROVIDER_NAME]: "anthropic",
|
|
57
|
-
[semconv_js_1.GEN_AI.MESSAGE_INDEX]: msgIndex,
|
|
58
|
-
},
|
|
31
|
+
provider,
|
|
32
|
+
messageIndex: msgIndex,
|
|
33
|
+
span,
|
|
59
34
|
});
|
|
60
35
|
msgIndex++;
|
|
61
36
|
}
|
|
@@ -66,38 +41,86 @@ function emitAnthropicMessageEvents(logger, messages, system) {
|
|
|
66
41
|
const role = typeof m.role === "string" ? m.role : "user";
|
|
67
42
|
const parts = (0, anthropic_content_js_1.contentToParts)(m.content);
|
|
68
43
|
const eventName = semconv_js_1.ROLE_TO_EVENT_NAME[role] ?? `gen_ai.${role}.message`;
|
|
69
|
-
|
|
44
|
+
(0, genai_content_js_1.emitMessageEvent)({
|
|
70
45
|
logger,
|
|
46
|
+
role,
|
|
47
|
+
parts,
|
|
71
48
|
eventName,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
[semconv_js_1.GEN_AI.MESSAGE_INDEX]: msgIndex,
|
|
76
|
-
},
|
|
49
|
+
provider,
|
|
50
|
+
messageIndex: msgIndex,
|
|
51
|
+
span,
|
|
77
52
|
});
|
|
78
53
|
msgIndex++;
|
|
79
54
|
}
|
|
80
55
|
}
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
* Port of _emit_choice_event from anthropic.py.
|
|
84
|
-
*/
|
|
85
|
-
function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason) {
|
|
56
|
+
/** Emit a gen_ai.choice LogRecord for an Anthropic response. */
|
|
57
|
+
function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason, span, provider = "anthropic") {
|
|
86
58
|
const parts = (0, anthropic_content_js_1.contentToParts)(contentBlocks);
|
|
87
59
|
const mappedReason = (stopReason && (semconv_js_1.ANTHROPIC_FINISH_REASON_MAP[stopReason] ?? stopReason)) ||
|
|
88
60
|
"stop";
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
61
|
+
(0, genai_content_js_1.emitChoiceEvent)({
|
|
62
|
+
logger,
|
|
63
|
+
parts,
|
|
64
|
+
finishReason: mappedReason,
|
|
65
|
+
provider,
|
|
66
|
+
span,
|
|
93
67
|
});
|
|
94
|
-
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Emit per-message log events for an OpenAI responses.create() call.
|
|
71
|
+
* `instructions` (the Responses system prompt) is emitted FIRST at index 0.
|
|
72
|
+
* Delegates LogRecord wiring to the shared emitters; only the Responses
|
|
73
|
+
* item → event mapping + ordering lives here.
|
|
74
|
+
*/
|
|
75
|
+
function emitOpenAIInputMessageEvents(logger, input, instructions, span, provider = "openai") {
|
|
76
|
+
let msgIndex = 0;
|
|
77
|
+
if (instructions) {
|
|
78
|
+
const parts = typeof instructions === "string"
|
|
79
|
+
? [{ type: "text", content: instructions }]
|
|
80
|
+
: [{ type: "text", content: String(instructions) }];
|
|
81
|
+
(0, genai_content_js_1.emitMessageEvent)({
|
|
82
|
+
logger,
|
|
83
|
+
role: "system",
|
|
84
|
+
parts,
|
|
85
|
+
eventName: semconv_js_1.EVENT_NAMES.SYSTEM_MESSAGE,
|
|
86
|
+
provider,
|
|
87
|
+
messageIndex: msgIndex,
|
|
88
|
+
span,
|
|
89
|
+
});
|
|
90
|
+
msgIndex++;
|
|
91
|
+
}
|
|
92
|
+
for (const item of (0, openai_content_js_1.normalizeInput)(input)) {
|
|
93
|
+
const mapped = (0, openai_content_js_1.inputItemToEvent)(item);
|
|
94
|
+
if (!mapped)
|
|
95
|
+
continue;
|
|
96
|
+
const [eventName, role, parts] = mapped;
|
|
97
|
+
(0, genai_content_js_1.emitMessageEvent)({
|
|
98
|
+
logger,
|
|
99
|
+
role,
|
|
100
|
+
parts,
|
|
101
|
+
eventName,
|
|
102
|
+
provider,
|
|
103
|
+
messageIndex: msgIndex,
|
|
104
|
+
span,
|
|
105
|
+
});
|
|
106
|
+
msgIndex++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Emit the gen_ai.choice LogRecord from an OpenAI response.output.
|
|
111
|
+
* `finishReason` is mapped inside this emitter using mapChoiceFinishReason.
|
|
112
|
+
*/
|
|
113
|
+
function emitOpenAIChoiceEvent(logger, output, finishReason, span, provider = "openai") {
|
|
114
|
+
const parts = [];
|
|
115
|
+
for (const item of Array.isArray(output) ? output : []) {
|
|
116
|
+
parts.push(...(0, openai_content_js_1.outputItemToChoiceParts)(item));
|
|
117
|
+
}
|
|
118
|
+
(0, genai_content_js_1.emitChoiceEvent)({
|
|
95
119
|
logger,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
},
|
|
120
|
+
parts,
|
|
121
|
+
finishReason: (0, openai_content_js_1.mapChoiceFinishReason)(finishReason),
|
|
122
|
+
provider,
|
|
123
|
+
span,
|
|
101
124
|
});
|
|
102
125
|
}
|
|
103
126
|
//# sourceMappingURL=events.js.map
|