@struct-ai/sdk 0.3.0 → 0.4.2
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 +101 -16
- package/dist/commonjs/context.d.ts +45 -0
- package/dist/commonjs/context.js +78 -1
- package/dist/commonjs/core.js +184 -29
- 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 +8 -1
- package/dist/commonjs/integrations/anthropic.js +515 -104
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
- package/dist/commonjs/integrations/langchain-callback.js +754 -87
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/langchain.d.ts +3 -0
- package/dist/commonjs/integrations/langchain.js +353 -7
- 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 +12 -0
- package/dist/commonjs/semconv.js +13 -1
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +2 -0
- package/dist/commonjs/version.js +6 -0
- package/dist/esm/context.d.ts +45 -0
- package/dist/esm/context.js +74 -1
- package/dist/esm/core.js +185 -30
- 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 +8 -1
- package/dist/esm/integrations/anthropic.js +514 -107
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +182 -27
- package/dist/esm/integrations/langchain-callback.js +756 -89
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/langchain.d.ts +3 -0
- package/dist/esm/integrations/langchain.js +352 -7
- 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 +12 -0
- package/dist/esm/semconv.js +12 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +3 -0
- package/package.json +11 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SpanKind, SpanStatusCode, context as otelContext, trace, } from "@opentelemetry/api";
|
|
2
|
-
import { ensurePendingToolCallsSlot, getAgentSpan, getSessionId, pushPendingToolCalls, runWithStore, snapshotStore, } from "../context.js";
|
|
2
|
+
import { ensurePendingToolCallsSlot, getAgentSpan, getSessionId, propagateProviderToParent, isGenAiSuppressed, pushPendingToolCalls, runWithContext, runWithStore, snapshotStore, } from "../context.js";
|
|
3
3
|
function childParentContext() {
|
|
4
4
|
const agentSpan = getAgentSpan();
|
|
5
5
|
return agentSpan
|
|
@@ -7,9 +7,11 @@ function childParentContext() {
|
|
|
7
7
|
: otelContext.active();
|
|
8
8
|
}
|
|
9
9
|
import { safe } from "../core.js";
|
|
10
|
+
import { detectProviderFromResource, } from "../genai-content.js";
|
|
10
11
|
import { emitAnthropicChoiceEvent, emitAnthropicMessageEvents, } from "../events.js";
|
|
12
|
+
import { propagateUserPromptToParent as sharedPropagateUserPromptToParent } from "../genai-content.js";
|
|
13
|
+
import { instrumentCall } from "../instrument.js";
|
|
11
14
|
import { ERROR_TYPE, GEN_AI, STRUCT, } from "../semconv.js";
|
|
12
|
-
import { truncateAndSerialize } from "../truncation.js";
|
|
13
15
|
import { iterToolUses, lastUserMessageParts, safeJsonForTool, toInputMessages, toOutputMessages, toSystemInstructions, } from "./anthropic-content.js";
|
|
14
16
|
const STRUCT_WRAPPED = Symbol.for("struct.wrapped");
|
|
15
17
|
const STRUCT_ORIGINAL = Symbol.for("struct.original");
|
|
@@ -97,65 +99,124 @@ function restoreMethod(proto, methodName) {
|
|
|
97
99
|
proto[methodName] = current[STRUCT_ORIGINAL];
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
|
-
|
|
102
|
+
// Exact client class names per platform (prototype chain covers subclasses)
|
|
103
|
+
// and official endpoint host shapes only — a generic cloud host (API-gateway,
|
|
104
|
+
// storage, arbitrary *.amazonaws.com/*.googleapis.com proxies) is NOT
|
|
105
|
+
// platform routing and must stay on the fallback. Parity: python anthropic.py.
|
|
106
|
+
const CLASS_NAME_RULES = [
|
|
107
|
+
// Mantle flavor extends BaseMantleClient, NOT the plain Bedrock base, so it
|
|
108
|
+
// needs its own names; default endpoint bedrock-mantle.{region}.api.aws.
|
|
109
|
+
[
|
|
110
|
+
new Set([
|
|
111
|
+
"AnthropicBedrock",
|
|
112
|
+
"BaseAnthropicBedrock",
|
|
113
|
+
"AnthropicBedrockMantle",
|
|
114
|
+
"BaseMantleClient",
|
|
115
|
+
]),
|
|
116
|
+
"aws.bedrock",
|
|
117
|
+
],
|
|
118
|
+
[new Set(["AnthropicVertex", "BaseAnthropicVertex"]), "gcp.vertex_ai"],
|
|
119
|
+
];
|
|
120
|
+
function isBedrockHost(host) {
|
|
121
|
+
if ((host.startsWith("bedrock-runtime.") ||
|
|
122
|
+
host.startsWith("bedrock-runtime-fips.")) &&
|
|
123
|
+
(host.endsWith(".amazonaws.com") || host.endsWith(".amazonaws.com.cn"))) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
// Bedrock Mantle default endpoint: bedrock-mantle.{region}.api.aws
|
|
127
|
+
return host.startsWith("bedrock-mantle.") && host.endsWith(".api.aws");
|
|
128
|
+
}
|
|
129
|
+
function isVertexHost(host) {
|
|
130
|
+
return (host === "aiplatform.googleapis.com" ||
|
|
131
|
+
host.endsWith("-aiplatform.googleapis.com"));
|
|
132
|
+
}
|
|
133
|
+
const HOST_RULES = [
|
|
134
|
+
[isBedrockHost, "aws.bedrock"],
|
|
135
|
+
[isVertexHost, "gcp.vertex_ai"],
|
|
136
|
+
];
|
|
137
|
+
function detectProvider(resource) {
|
|
138
|
+
return detectProviderFromResource(resource, CLASS_NAME_RULES, HOST_RULES, "anthropic");
|
|
139
|
+
}
|
|
140
|
+
/** @internal */
|
|
141
|
+
export const _detectProviderForTest = detectProvider;
|
|
142
|
+
/** @internal */
|
|
143
|
+
export const _setChatRequestAttrsForTest = (span, params, sdk, logger, provider) => setChatRequestAttrs(span, params, sdk, logger, provider);
|
|
144
|
+
export function wrapCreate(original) {
|
|
101
145
|
return function wrappedCreate(params, opts) {
|
|
102
146
|
const patchCtx = activePatchCtx.value;
|
|
103
147
|
if (!patchCtx) {
|
|
104
148
|
return original.call(this, params, opts);
|
|
105
149
|
}
|
|
150
|
+
// CLASS RULE: detection => propagation, immediately and on EVERY path
|
|
151
|
+
// (see openai.ts). Guarded; write-once makes repeats harmless.
|
|
152
|
+
const provider = detectProvider(this);
|
|
153
|
+
propagateProviderToParent(provider);
|
|
154
|
+
if (isGenAiSuppressed()) {
|
|
155
|
+
// A framework layer owns this chat span — run the call, emit no span.
|
|
156
|
+
return original.call(this, params, opts);
|
|
157
|
+
}
|
|
158
|
+
// Preflight reads touch CALLER-owned `params` — a Proxy / lazy request
|
|
159
|
+
// object can have throwing getters; a throw here would prevent the API
|
|
160
|
+
// call. Degrade: exactly one plain, uninstrumented original.call.
|
|
161
|
+
let streaming = false;
|
|
162
|
+
let model = "unknown";
|
|
163
|
+
try {
|
|
164
|
+
streaming = !!(params && params.stream === true);
|
|
165
|
+
model = params?.model ?? "unknown";
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return original.call(this, params, opts);
|
|
169
|
+
}
|
|
106
170
|
// If params.stream is true, Messages.create delegates to the streaming path.
|
|
107
171
|
// We still need to instrument the returned stream like we do in messages.stream.
|
|
108
|
-
if (
|
|
172
|
+
if (streaming) {
|
|
109
173
|
return wrapStream(original).call(this, params, opts);
|
|
110
174
|
}
|
|
111
175
|
const { tracer, sdk, logger } = patchCtx;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const liveSpan = span;
|
|
128
|
-
const liveCtx = ctx;
|
|
129
|
-
// Enter the span's context BEFORE emitting request-side log records so
|
|
130
|
-
// `logger.emit({ context: otelContext.active() })` picks up the chat
|
|
131
|
-
// span as the log's TraceId / SpanId. Without this the UI can't link
|
|
132
|
-
// user/system message logs back to the chat span.
|
|
133
|
-
return otelContext.with(liveCtx, () => {
|
|
134
|
-
safe(() => setChatRequestAttrs(liveSpan, params, sdk, logger), "anthropic.create.set_request_attrs", internalLogger);
|
|
135
|
-
const promise = Promise.resolve(original.call(this, params, opts));
|
|
136
|
-
return promise.then((result) => {
|
|
137
|
-
safe(() => setChatResponseAttrs(liveSpan, sdk, result, logger), "anthropic.create.set_response_attrs", internalLogger);
|
|
138
|
-
safe(() => liveSpan.setStatus({ code: SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
|
|
139
|
-
safe(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
|
|
140
|
-
return result;
|
|
141
|
-
}, (err) => {
|
|
142
|
-
safe(() => recordErrorOnSpan(liveSpan, err), "anthropic.create.record_error", internalLogger);
|
|
143
|
-
safe(() => liveSpan.end(), "anthropic.create.span_end_on_error", internalLogger);
|
|
144
|
-
// User's API exception always propagates unchanged.
|
|
145
|
-
throw err;
|
|
146
|
-
});
|
|
176
|
+
// All host-boundary safety lives in the shared instrumentCall harness: the
|
|
177
|
+
// host call runs exactly once, nothing host-controllable wraps it, and every
|
|
178
|
+
// telemetry side-effect degrades via safe(). Log-record→span linkage is
|
|
179
|
+
// carried by the explicit span passed to the emitters, so no
|
|
180
|
+
// `otelContext.with(...)` is needed around the call.
|
|
181
|
+
return instrumentCall(() => original.call(this, params, opts), {
|
|
182
|
+
tracer,
|
|
183
|
+
spanName: `chat ${model}`,
|
|
184
|
+
spanKind: SpanKind.CLIENT,
|
|
185
|
+
parentContext: childParentContext,
|
|
186
|
+
sitePrefix: "anthropic.create",
|
|
187
|
+
internalLogger: sdk.getInternalLogger(),
|
|
188
|
+
onStart: (span) => setChatRequestAttrs(span, params, sdk, logger, provider),
|
|
189
|
+
onSuccess: (span, result) => setChatResponseAttrs(span, sdk, result, logger, provider),
|
|
190
|
+
onError: (span, err) => recordErrorOnSpan(span, err),
|
|
147
191
|
});
|
|
148
192
|
};
|
|
149
193
|
}
|
|
150
|
-
function wrapStream(original) {
|
|
194
|
+
export function wrapStream(original) {
|
|
151
195
|
return function wrappedStream(params, opts) {
|
|
152
196
|
const patchCtx = activePatchCtx.value;
|
|
153
197
|
if (!patchCtx) {
|
|
154
198
|
return original.call(this, params, opts);
|
|
155
199
|
}
|
|
200
|
+
// CLASS RULE: detection => propagation, immediately and on EVERY path —
|
|
201
|
+
// when suppressed (langchain owns the span), the raw client here is the
|
|
202
|
+
// only layer that can detect the real platform for the agent span.
|
|
203
|
+
const streamProvider = detectProvider(this);
|
|
204
|
+
propagateProviderToParent(streamProvider);
|
|
205
|
+
if (isGenAiSuppressed()) {
|
|
206
|
+
// A framework layer owns this chat span — run the call, emit no span.
|
|
207
|
+
return original.call(this, params, opts);
|
|
208
|
+
}
|
|
156
209
|
const { tracer, sdk, logger } = patchCtx;
|
|
157
210
|
const internalLogger = sdk.getInternalLogger();
|
|
158
|
-
|
|
211
|
+
// Preflight read of CALLER-owned params — a throwing `model` getter must
|
|
212
|
+
// degrade to one plain uninstrumented call, never block the stream.
|
|
213
|
+
let model = "unknown";
|
|
214
|
+
try {
|
|
215
|
+
model = params?.model ?? "unknown";
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return original.call(this, params, opts);
|
|
219
|
+
}
|
|
159
220
|
// Span creation itself can fail. If it does, fall through to calling
|
|
160
221
|
// `original` directly so the user's stream call always runs. Mirrors
|
|
161
222
|
// the Python `_wrap_stream` fall-through pattern.
|
|
@@ -172,13 +233,6 @@ function wrapStream(original) {
|
|
|
172
233
|
const liveSpan = span;
|
|
173
234
|
const liveCtx = ctx;
|
|
174
235
|
const storeSnapshot = snapshotStore();
|
|
175
|
-
// Enter the span's context BEFORE request-side log emission so logs
|
|
176
|
-
// link back to the chat span via TraceId. Same rationale as wrapCreate.
|
|
177
|
-
// @anthropic-ai/sdk's `stream()` returns a MessageStream synchronously.
|
|
178
|
-
const stream = otelContext.with(liveCtx, () => {
|
|
179
|
-
safe(() => setChatRequestAttrs(liveSpan, params, sdk, logger), "anthropic.stream.set_request_attrs", internalLogger);
|
|
180
|
-
return original.call(this, params, opts);
|
|
181
|
-
});
|
|
182
236
|
let finalized = false;
|
|
183
237
|
const finalize = (result, err) => {
|
|
184
238
|
if (finalized)
|
|
@@ -189,7 +243,7 @@ function wrapStream(original) {
|
|
|
189
243
|
safe(() => recordErrorOnSpan(liveSpan, err), "anthropic.stream.record_error", internalLogger);
|
|
190
244
|
}
|
|
191
245
|
else if (result) {
|
|
192
|
-
safe(() => otelContext.with(liveCtx, () => setChatResponseAttrs(liveSpan, sdk, result, logger)), "anthropic.stream.set_response_attrs", internalLogger);
|
|
246
|
+
safe(() => otelContext.with(liveCtx, () => setChatResponseAttrs(liveSpan, sdk, result, logger, streamProvider)), "anthropic.stream.set_response_attrs", internalLogger);
|
|
193
247
|
safe(() => liveSpan.setStatus({ code: SpanStatusCode.OK }), "anthropic.stream.set_ok_status", internalLogger);
|
|
194
248
|
}
|
|
195
249
|
else {
|
|
@@ -198,48 +252,416 @@ function wrapStream(original) {
|
|
|
198
252
|
safe(() => liveSpan.end(), "anthropic.stream.span_end", internalLogger);
|
|
199
253
|
});
|
|
200
254
|
};
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
255
|
+
// Enter the span's context BEFORE request-side log emission so logs
|
|
256
|
+
// link back to the chat span via TraceId. Same rationale as wrapCreate.
|
|
257
|
+
// @anthropic-ai/sdk's `stream()` returns a MessageStream synchronously,
|
|
258
|
+
// but `messages.create({stream:true})` (delegated here from wrapCreate)
|
|
259
|
+
// returns an APIPromise-like thenable resolving to a raw event Stream —
|
|
260
|
+
// both shapes are handled below.
|
|
261
|
+
//
|
|
262
|
+
// Suppress nested instrumentation: the real `Messages.prototype.stream()`
|
|
263
|
+
// (`original` here) internally calls `MessageStream.createMessage`, whose
|
|
264
|
+
// executor synchronously invokes `messages.create({ ...params, stream:
|
|
265
|
+
// true }, ...)` — on the SAME patched `Messages` prototype — before its
|
|
266
|
+
// first `await` yields control. That resolves to our `wrapCreate`
|
|
267
|
+
// wrapper, which would otherwise see `params.stream === true` and
|
|
268
|
+
// re-delegate to a brand-new `wrapStream(original)`, instrumenting the
|
|
269
|
+
// same underlying raw stream a second time (2 chat spans, same
|
|
270
|
+
// gen_ai.response.id). `runWithContext({ suppressGenAi: true }, ...)`
|
|
271
|
+
// scopes an ALS store patch around exactly this synchronous call (plus
|
|
272
|
+
// any promise chain it kicks off internally, since AsyncLocalStorage
|
|
273
|
+
// propagates into `.then` continuations created within the `run()`
|
|
274
|
+
// callback) — `wrapCreate` checks `isGenAiSuppressed()` before its
|
|
275
|
+
// `stream === true` branch and, when suppressed, falls through to
|
|
276
|
+
// calling the TRUE raw `create` directly (no span, no re-wrap). This
|
|
277
|
+
// must NOT leak into user code: the store patch is popped the instant
|
|
278
|
+
// `original.call(...)` returns (it returns the MessageStream
|
|
279
|
+
// synchronously), so by the time control returns to the caller of
|
|
280
|
+
// `.stream()`, suppression is already off again — user code that later
|
|
281
|
+
// iterates the stream, or that itself calls `messages.create`/`.stream`,
|
|
282
|
+
// runs with a clean (unsuppressed) ALS store.
|
|
283
|
+
let stream;
|
|
284
|
+
try {
|
|
285
|
+
safe(() => setChatRequestAttrs(liveSpan, params, sdk, logger, streamProvider), "anthropic.stream.set_request_attrs", internalLogger);
|
|
286
|
+
// `suppressGenAi` is ALS-based (`als.run`), NOT host-controllable, so it
|
|
287
|
+
// may wrap the original stream call. We deliberately do NOT wrap it in
|
|
288
|
+
// `otelContext.with(liveCtx, ...)`: a customer's global ContextManager
|
|
289
|
+
// could throw there and block the stream (host-fault). Log-record→span
|
|
290
|
+
// linkage is carried by the explicit span passed inside
|
|
291
|
+
// setChatRequestAttrs, not by ambient context.
|
|
292
|
+
stream = runWithContext({ suppressGenAi: true }, () => original.call(this, params, opts));
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
// Sync throw from the original call would otherwise leak the span —
|
|
296
|
+
// no stream/promise is ever produced to drive finalize() below.
|
|
297
|
+
finalize(undefined, err);
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
// The entire branch-dispatch region below — reading `ee.on`/`.then`/
|
|
301
|
+
// `Symbol.asyncIterator`, registering `.on(...)` listeners, invoking
|
|
302
|
+
// `.then(...)`, and calling `instrumentSyncIterable` — runs SYNCHRONOUSLY
|
|
303
|
+
// on the success path of the customer's `.stream()`/`.create()` call, and
|
|
304
|
+
// was previously unguarded (round-4 only hardened the ASYNC observer
|
|
305
|
+
// callbacks registered inside it, e.g. the onFulfilled/onRejected
|
|
306
|
+
// handlers below). A hostile/broken stream-like object — a throwing
|
|
307
|
+
// `.on`/`.then`/`Symbol.asyncIterator` getter, or a `.on()` registration
|
|
308
|
+
// call that itself throws — would throw synchronously OUT of the
|
|
309
|
+
// customer's call. Wrap the whole dispatch in try/catch so a successful
|
|
310
|
+
// request never becomes a hard failure purely because OUR observer
|
|
311
|
+
// wiring couldn't attach: degrade to no telemetry and hand back the
|
|
312
|
+
// ORIGINAL stream object unchanged.
|
|
313
|
+
try {
|
|
314
|
+
const ee = stream;
|
|
315
|
+
// Emitter path wins first — a real MessageStream (`.on`) is never
|
|
316
|
+
// thenable, but check `.on` before `.then` defensively in case some
|
|
317
|
+
// future stream shape exposes both.
|
|
318
|
+
if (ee && typeof ee.on === "function") {
|
|
319
|
+
ee.on("finalMessage", (msg) => {
|
|
320
|
+
finalize(msg, undefined);
|
|
321
|
+
});
|
|
322
|
+
ee.on("error", (err) => {
|
|
323
|
+
finalize(undefined, err);
|
|
324
|
+
});
|
|
325
|
+
// Abort fires before "end" on an aborted stream — without this
|
|
326
|
+
// listener the "end" handler below finalizes with OK status even
|
|
327
|
+
// though the request was aborted. `finalized` makes the subsequent
|
|
328
|
+
// "end" a no-op.
|
|
329
|
+
ee.on("abort", (err) => {
|
|
330
|
+
finalize(undefined, err ?? new Error("aborted"));
|
|
331
|
+
});
|
|
332
|
+
// If the stream is created and never iterated / finalized, at least
|
|
333
|
+
// don't hang on test exit — "end" fires after iteration completes.
|
|
334
|
+
ee.on("end", () => {
|
|
335
|
+
// Only fires if no finalMessage / error / abort fired first (rare
|
|
336
|
+
// path for a healthy, fully-drained stream).
|
|
337
|
+
finalize(undefined, undefined);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else if (ee && typeof ee.then === "function") {
|
|
341
|
+
// `create({stream:true})` shape: an APIPromise-like thenable that
|
|
342
|
+
// resolves to a raw event Stream (no `.on`, no synchronous
|
|
343
|
+
// asyncIterator until resolved). Observe the resolution to instrument
|
|
344
|
+
// the resolved Stream BY MUTATION, but return the ORIGINAL thenable —
|
|
345
|
+
// Anthropic's APIPromise exposes methods like `.withResponse()` /
|
|
346
|
+
// `.asResponse()` that a native `Promise.resolve(...).then(...)` would
|
|
347
|
+
// strip, breaking customer code only when instrumentation is enabled.
|
|
348
|
+
// Our observer registers synchronously here, before the caller can
|
|
349
|
+
// await, so instrumentRawStream mutates the same resolved object the
|
|
350
|
+
// caller receives, before they iterate it.
|
|
351
|
+
const thenable = stream;
|
|
352
|
+
const observerChain = thenable.then((raw) => {
|
|
353
|
+
// instrumentRawStream can itself throw at points outside its OWN
|
|
354
|
+
// internal try/catch (e.g. a hostile `Symbol.asyncIterator`
|
|
355
|
+
// getter, read before that internal guard) — this must not
|
|
356
|
+
// escape THIS onFulfilled callback: an uncaught throw here
|
|
357
|
+
// becomes an unhandled rejection on the promise `.then()`
|
|
358
|
+
// returns, even though the customer's original APIPromise
|
|
359
|
+
// resolved successfully — a successful request must never crash
|
|
360
|
+
// the host. Degrade: end the span with no telemetry and swallow.
|
|
361
|
+
try {
|
|
362
|
+
instrumentRawStream(raw);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
finalize(undefined, undefined);
|
|
366
|
+
}
|
|
367
|
+
}, (err) => {
|
|
368
|
+
// Observe the rejection to finalize the span, but do NOT rethrow:
|
|
369
|
+
// this observer branch must never surface as an unhandled
|
|
370
|
+
// rejection. The original thenable still rejects to the caller
|
|
371
|
+
// independently.
|
|
372
|
+
finalize(undefined, err);
|
|
373
|
+
});
|
|
374
|
+
// Belt-and-suspenders: both branches above are already guarded to
|
|
375
|
+
// never throw/reject, so this derived promise should never reject —
|
|
376
|
+
// but chain a no-op `.catch` anyway so this internal observer chain
|
|
377
|
+
// can NEVER become an unhandled-rejection source, even under future
|
|
378
|
+
// edits to the branches above.
|
|
379
|
+
observerChain.catch?.(() => { });
|
|
380
|
+
return stream;
|
|
381
|
+
}
|
|
382
|
+
else if (ee && typeof ee[Symbol.asyncIterator] === "function") {
|
|
383
|
+
// Wrap the async iterator so each .next() runs inside the captured
|
|
384
|
+
// ALS store.
|
|
385
|
+
instrumentSyncIterable(ee);
|
|
386
|
+
}
|
|
387
|
+
return stream;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
finalize(undefined, undefined);
|
|
391
|
+
return stream;
|
|
392
|
+
}
|
|
393
|
+
function instrumentRawStream(raw) {
|
|
394
|
+
const it = raw;
|
|
395
|
+
if (!it || typeof it[Symbol.asyncIterator] !== "function") {
|
|
396
|
+
finalize(undefined, undefined); // not iterable — end the span rather than leak it
|
|
397
|
+
return raw;
|
|
398
|
+
}
|
|
399
|
+
// A raw stream that is awaited but never iterated would otherwise leak
|
|
400
|
+
// its span — finalize() only fires through the iterator hooks below.
|
|
401
|
+
// Anthropic's Stream carries an AbortController; close the span if the
|
|
402
|
+
// request is aborted before/without iteration. Residual (documented,
|
|
403
|
+
// accepted): a stream awaited, never iterated, AND never aborted stays
|
|
404
|
+
// open until process exit — no lifecycle signal exists to close it.
|
|
405
|
+
//
|
|
406
|
+
// This whole section reads a `controller` getter and calls
|
|
407
|
+
// `addEventListener` on whatever object the SDK (or a hostile/broken
|
|
408
|
+
// caller-supplied stand-in) hands us — both are fallible and, unlike
|
|
409
|
+
// the `Symbol.asyncIterator` assignment below, were previously
|
|
410
|
+
// unguarded. Wrap it so a throwing getter degrades to "no abort-driven
|
|
411
|
+
// finalize wiring" rather than aborting instrumentRawStream entirely
|
|
412
|
+
// (which would also skip the Symbol.asyncIterator instrumentation
|
|
413
|
+
// below and lose the whole stream's telemetry, not just the abort
|
|
414
|
+
// hook). The caller (the observer's onFulfilled, or the synchronous
|
|
415
|
+
// `.on()`/asyncIterator paths in wrapStream) also guards its own call
|
|
416
|
+
// to instrumentRawStream, so this is defense-in-depth, not the only
|
|
417
|
+
// guard.
|
|
418
|
+
try {
|
|
419
|
+
const ctrl = raw.controller;
|
|
420
|
+
const sig = ctrl?.signal;
|
|
421
|
+
if (sig) {
|
|
422
|
+
if (sig.aborted) {
|
|
423
|
+
finalize(undefined, sig.reason ?? new Error("aborted"));
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
sig.addEventListener("abort", () => finalize(undefined, sig.reason ?? new Error("aborted")), { once: true });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Swallow — no abort-driven finalize wiring for this stream, but
|
|
432
|
+
// fall through to installing the Symbol.asyncIterator override
|
|
433
|
+
// below so normal iteration still drives accumulate()/finalize().
|
|
434
|
+
}
|
|
435
|
+
const acc = {};
|
|
436
|
+
const originalIter = it[Symbol.asyncIterator].bind(it);
|
|
437
|
+
try {
|
|
438
|
+
it[Symbol.asyncIterator] = () => {
|
|
439
|
+
const inner = originalIter();
|
|
440
|
+
return {
|
|
441
|
+
next: () => runWithStore(storeSnapshot, () => inner.next().then((res) => {
|
|
442
|
+
// accumulate()/accToResponseMessage() inspect provider-owned
|
|
443
|
+
// values (a proxy getter can throw; accToResponseMessage
|
|
444
|
+
// JSON.parses input_json_delta, which may be malformed). A
|
|
445
|
+
// successfully yielded item must NEVER become a rejected
|
|
446
|
+
// next(): on any instrumentation failure, degrade telemetry
|
|
447
|
+
// (finalize with no result) and return the original res.
|
|
448
|
+
try {
|
|
449
|
+
if (!res.done)
|
|
450
|
+
accumulate(acc, res.value);
|
|
451
|
+
else
|
|
452
|
+
finalize(accToResponseMessage(acc), undefined);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
finalize(undefined, undefined);
|
|
456
|
+
}
|
|
457
|
+
return res;
|
|
458
|
+
}, (err) => {
|
|
459
|
+
finalize(undefined, err);
|
|
460
|
+
throw err;
|
|
461
|
+
})),
|
|
462
|
+
return: (v) => runWithStore(storeSnapshot, () => {
|
|
463
|
+
// Settle the inner iterator's cleanup BEFORE finalizing, so a
|
|
464
|
+
// cleanup rejection is recorded as ERROR rather than the span
|
|
465
|
+
// being closed OK while the caller receives a rejection.
|
|
466
|
+
const ret = inner.return
|
|
467
|
+
? inner.return(v)
|
|
468
|
+
: Promise.resolve({ value: v, done: true });
|
|
469
|
+
return Promise.resolve(ret).then((res) => {
|
|
470
|
+
finalize(accToResponseMessage(acc), undefined); // early break — close OK
|
|
471
|
+
return res;
|
|
472
|
+
}, (err) => {
|
|
473
|
+
finalize(undefined, err); // cleanup rejected — record ERROR
|
|
474
|
+
throw err; // propagate the original rejection unchanged
|
|
475
|
+
});
|
|
476
|
+
}),
|
|
477
|
+
throw: (e) => runWithStore(storeSnapshot, () => {
|
|
478
|
+
// The thrown value IS the error condition, so finalize ERROR
|
|
479
|
+
// with it regardless of the inner iterator's cleanup outcome.
|
|
480
|
+
finalize(undefined, e);
|
|
481
|
+
return inner.throw ? inner.throw(e) : Promise.reject(e);
|
|
482
|
+
}),
|
|
483
|
+
};
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// The resolved stream object is frozen/sealed or the property is
|
|
488
|
+
// non-writable — assigning Symbol.asyncIterator threw. A successful
|
|
489
|
+
// request must never become a rejected promise because OUR
|
|
490
|
+
// instrumentation couldn't attach; degrade gracefully (end the span
|
|
491
|
+
// with no telemetry) and hand back the untouched object. No Proxy
|
|
492
|
+
// fallback — that would change the stream's identity, which is its
|
|
493
|
+
// own hazard.
|
|
213
494
|
finalize(undefined, undefined);
|
|
214
|
-
}
|
|
495
|
+
}
|
|
496
|
+
return raw;
|
|
215
497
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
: Promise.reject(err));
|
|
232
|
-
|
|
498
|
+
function instrumentSyncIterable(target) {
|
|
499
|
+
const originalIter = target[Symbol.asyncIterator].bind(target);
|
|
500
|
+
try {
|
|
501
|
+
target[Symbol.asyncIterator] = () => {
|
|
502
|
+
const inner = originalIter();
|
|
503
|
+
const wrapped = {
|
|
504
|
+
next() {
|
|
505
|
+
return runWithStore(storeSnapshot, () => inner.next());
|
|
506
|
+
},
|
|
507
|
+
return(value) {
|
|
508
|
+
return runWithStore(storeSnapshot, () => inner.return
|
|
509
|
+
? inner.return(value)
|
|
510
|
+
: Promise.resolve({ value, done: true }));
|
|
511
|
+
},
|
|
512
|
+
throw(err) {
|
|
513
|
+
return runWithStore(storeSnapshot, () => inner.throw ? inner.throw(err) : Promise.reject(err));
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
return wrapped;
|
|
233
517
|
};
|
|
234
|
-
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
// Same hazard class as instrumentRawStream above: the target is
|
|
521
|
+
// frozen/sealed or the property is non-writable. This call happens
|
|
522
|
+
// synchronously inside wrapStream itself (not inside a promise
|
|
523
|
+
// callback), so an uncaught throw here would surface as a
|
|
524
|
+
// SYNCHRONOUS exception out of the user's `.stream()`/`.create()`
|
|
525
|
+
// call — turning a successful request into a hard failure. Degrade
|
|
526
|
+
// gracefully instead: end the span with no telemetry and leave the
|
|
527
|
+
// iterable completely untouched (the failed assignment never took
|
|
528
|
+
// effect).
|
|
529
|
+
finalize(undefined, undefined);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function accumulate(acc, ev) {
|
|
533
|
+
const e = ev;
|
|
534
|
+
if (e?.type === "message_start" && e.message) {
|
|
535
|
+
acc.id = e.message.id;
|
|
536
|
+
acc.model = e.message.model;
|
|
537
|
+
acc.inputTokens = e.message.usage?.input_tokens;
|
|
538
|
+
// Cached `create({stream:true})` requests carry cache token counts
|
|
539
|
+
// on message_start's usage (never on message_delta's) — without
|
|
540
|
+
// these, the synthesized ResponseMessage under-reports input usage
|
|
541
|
+
// (setChatResponseAttrs adds them into the total) and omits both
|
|
542
|
+
// cache attributes entirely, unlike the non-streaming and
|
|
543
|
+
// MessageStream (`.on("finalMessage")`) paths, which pass the SDK's
|
|
544
|
+
// own parsed usage straight through.
|
|
545
|
+
acc.cacheReadTokens = e.message.usage?.cache_read_input_tokens;
|
|
546
|
+
acc.cacheCreationTokens = e.message.usage?.cache_creation_input_tokens;
|
|
547
|
+
}
|
|
548
|
+
else if (e?.type === "content_block_start" && typeof e.index === "number") {
|
|
549
|
+
// Assemble content blocks so the synthesized response carries
|
|
550
|
+
// `content` — without it, setChatResponseAttrs's `if (response.content)`
|
|
551
|
+
// block never runs on this raw-stream path, dropping
|
|
552
|
+
// gen_ai.output.messages, the gen_ai.choice event, and (critically)
|
|
553
|
+
// recordPendingToolCalls, so a streamed tool_use never links to its
|
|
554
|
+
// execute_tool span. Mirrors MessageStream's finalMessage assembly,
|
|
555
|
+
// minimally: text blocks and tool_use blocks only.
|
|
556
|
+
const blocks = (acc.blocks ??= {});
|
|
557
|
+
const cb = e.content_block;
|
|
558
|
+
if (cb?.type === "tool_use") {
|
|
559
|
+
blocks[e.index] = {
|
|
560
|
+
type: "tool_use",
|
|
561
|
+
id: cb.id,
|
|
562
|
+
name: cb.name,
|
|
563
|
+
jsonBuf: "",
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
else if (cb?.type === "text") {
|
|
567
|
+
blocks[e.index] = { type: "text", text: cb.text ?? "" };
|
|
568
|
+
}
|
|
569
|
+
else if (cb?.type) {
|
|
570
|
+
blocks[e.index] = { type: cb.type };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else if (e?.type === "content_block_delta" && typeof e.index === "number") {
|
|
574
|
+
const blocks = (acc.blocks ??= {});
|
|
575
|
+
const b = blocks[e.index];
|
|
576
|
+
if (!b)
|
|
577
|
+
return;
|
|
578
|
+
if (e.delta?.type === "text_delta" && typeof e.delta.text === "string") {
|
|
579
|
+
b.text = (b.text ?? "") + e.delta.text;
|
|
580
|
+
}
|
|
581
|
+
else if (e.delta?.type === "input_json_delta" &&
|
|
582
|
+
typeof e.delta.partial_json === "string") {
|
|
583
|
+
b.jsonBuf = (b.jsonBuf ?? "") + e.delta.partial_json;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
else if (e?.type === "content_block_stop" && typeof e.index === "number") {
|
|
587
|
+
const blocks = (acc.blocks ??= {});
|
|
588
|
+
const b = blocks[e.index];
|
|
589
|
+
if (b?.type === "tool_use") {
|
|
590
|
+
// Parse the accumulated partial_json into the tool_use input; fall
|
|
591
|
+
// back to the raw string if the model streamed malformed JSON (the
|
|
592
|
+
// block still links via id/name, which is what tool-call linkage
|
|
593
|
+
// needs).
|
|
594
|
+
try {
|
|
595
|
+
b.input = b.jsonBuf ? JSON.parse(b.jsonBuf) : {};
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
b.input = b.jsonBuf;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
else if (e?.type === "message_delta") {
|
|
603
|
+
if (e.delta?.stop_reason)
|
|
604
|
+
acc.stopReason = e.delta.stop_reason;
|
|
605
|
+
if (e.usage?.output_tokens !== undefined) {
|
|
606
|
+
acc.outputTokens = e.usage.output_tokens;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// NOTE (parity, intentional divergence — self-audit round 5, item G):
|
|
611
|
+
// this synthesizes a FULL ResponseMessage (content, usage, stop_reason,
|
|
612
|
+
// id, model) from the raw SSE event stream, matching what the
|
|
613
|
+
// non-streaming `create()` path and `MessageStream`'s `finalMessage`
|
|
614
|
+
// event already carry. struct-sdk-python's `create(stream=True)` raw
|
|
615
|
+
// path does NOT do this reconstruction yet and emits none of these
|
|
616
|
+
// response-side attributes for that one call shape. This is TS being
|
|
617
|
+
// MORE complete than Python here, not a TS bug — do not remove it to
|
|
618
|
+
// "match" Python. Bringing Python's raw-stream path up to parity is a
|
|
619
|
+
// separate, independent follow-up.
|
|
620
|
+
function accToResponseMessage(acc) {
|
|
621
|
+
const blocks = acc.blocks;
|
|
622
|
+
if (acc.id === undefined && acc.inputTokens === undefined && !blocks) {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
// Rebuild content in ascending block index, shaped like the SDK's own
|
|
626
|
+
// parsed message content so setChatResponseAttrs / iterToolUses read it
|
|
627
|
+
// identically to the non-streaming path.
|
|
628
|
+
let content;
|
|
629
|
+
if (blocks) {
|
|
630
|
+
content = Object.keys(blocks)
|
|
631
|
+
.map((k) => Number(k))
|
|
632
|
+
.sort((a, b) => a - b)
|
|
633
|
+
.map((i) => {
|
|
634
|
+
const b = blocks[i];
|
|
635
|
+
if (b.type === "tool_use") {
|
|
636
|
+
return { type: "tool_use", id: b.id, name: b.name, input: b.input ?? {} };
|
|
637
|
+
}
|
|
638
|
+
if (b.type === "text") {
|
|
639
|
+
return { type: "text", text: b.text ?? "" };
|
|
640
|
+
}
|
|
641
|
+
return { type: b.type };
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
id: acc.id,
|
|
646
|
+
model: acc.model,
|
|
647
|
+
stop_reason: acc.stopReason,
|
|
648
|
+
content: content,
|
|
649
|
+
usage: {
|
|
650
|
+
input_tokens: acc.inputTokens,
|
|
651
|
+
output_tokens: acc.outputTokens,
|
|
652
|
+
// Raw fields only — setChatResponseAttrs remains the single place
|
|
653
|
+
// that adds these into the reported total, matching the
|
|
654
|
+
// non-streaming/MessageStream contract exactly.
|
|
655
|
+
cache_read_input_tokens: acc.cacheReadTokens,
|
|
656
|
+
cache_creation_input_tokens: acc.cacheCreationTokens,
|
|
657
|
+
},
|
|
235
658
|
};
|
|
236
659
|
}
|
|
237
|
-
return stream;
|
|
238
660
|
};
|
|
239
661
|
}
|
|
240
|
-
function setChatRequestAttrs(span, params, sdk, logger) {
|
|
662
|
+
function setChatRequestAttrs(span, params, sdk, logger, provider = "anthropic") {
|
|
241
663
|
span.setAttribute(GEN_AI.OPERATION_NAME, "chat");
|
|
242
|
-
span.setAttribute(GEN_AI.PROVIDER_NAME,
|
|
664
|
+
span.setAttribute(GEN_AI.PROVIDER_NAME, provider);
|
|
243
665
|
const model = params?.model ?? "unknown";
|
|
244
666
|
span.setAttribute(GEN_AI.REQUEST_MODEL, model);
|
|
245
667
|
const sessionId = getSessionId();
|
|
@@ -263,10 +685,15 @@ function setChatRequestAttrs(span, params, sdk, logger) {
|
|
|
263
685
|
}
|
|
264
686
|
if (Array.isArray(params?.messages)) {
|
|
265
687
|
span.setAttribute(STRUCT.INPUT_MESSAGE_COUNT, params.messages.length);
|
|
266
|
-
|
|
688
|
+
// Parent-prompt preview is CONTENT — never emit it in ContentCaptureMode.None.
|
|
689
|
+
// Deliberately kept in EventOnly (the default): shipped behavior in both
|
|
690
|
+
// SDKs and what the waterfall UI reads.
|
|
691
|
+
if (sdk.captureContent) {
|
|
692
|
+
sharedPropagateUserPromptToParent(lastUserMessageParts(params.messages));
|
|
693
|
+
}
|
|
267
694
|
}
|
|
268
695
|
if (sdk.emitEvents && logger && Array.isArray(params?.messages)) {
|
|
269
|
-
emitAnthropicMessageEvents(logger, params.messages, params.system);
|
|
696
|
+
emitAnthropicMessageEvents(logger, params.messages, params.system, span, provider);
|
|
270
697
|
}
|
|
271
698
|
if (sdk.emitSpanContent) {
|
|
272
699
|
if (Array.isArray(params?.messages)) {
|
|
@@ -280,7 +707,7 @@ function setChatRequestAttrs(span, params, sdk, logger) {
|
|
|
280
707
|
}
|
|
281
708
|
}
|
|
282
709
|
}
|
|
283
|
-
function setChatResponseAttrs(span, sdk, response, logger) {
|
|
710
|
+
function setChatResponseAttrs(span, sdk, response, logger, provider = "anthropic") {
|
|
284
711
|
const usage = response.usage;
|
|
285
712
|
if (usage) {
|
|
286
713
|
const inputTokens = usage.input_tokens ?? 0;
|
|
@@ -310,7 +737,7 @@ function setChatResponseAttrs(span, sdk, response, logger) {
|
|
|
310
737
|
// Always runs — populates pending queue regardless of content-capture mode.
|
|
311
738
|
recordPendingToolCalls(response.content);
|
|
312
739
|
if (sdk.emitEvents && logger) {
|
|
313
|
-
emitAnthropicChoiceEvent(logger, response.content, response.stop_reason);
|
|
740
|
+
emitAnthropicChoiceEvent(logger, response.content, response.stop_reason, span, provider);
|
|
314
741
|
}
|
|
315
742
|
if (sdk.emitSpanContent) {
|
|
316
743
|
span.setAttribute(GEN_AI.OUTPUT_MESSAGES, toOutputMessages(response.content, response.stop_reason));
|
|
@@ -326,26 +753,6 @@ function recordPendingToolCalls(contentBlocks) {
|
|
|
326
753
|
ensurePendingToolCallsSlot();
|
|
327
754
|
pushPendingToolCalls(pairs);
|
|
328
755
|
}
|
|
329
|
-
function propagateUserPromptToParent(messages) {
|
|
330
|
-
try {
|
|
331
|
-
const agentSpan = getAgentSpan();
|
|
332
|
-
if (!agentSpan)
|
|
333
|
-
return;
|
|
334
|
-
// The SDK span type doesn't expose `attributes` — we use a brand check on
|
|
335
|
-
// the SDK's ReadableSpan-like shape.
|
|
336
|
-
const agentAttrs = agentSpan
|
|
337
|
-
.attributes;
|
|
338
|
-
if (agentAttrs && agentAttrs[GEN_AI.INPUT_MESSAGES])
|
|
339
|
-
return;
|
|
340
|
-
const parts = lastUserMessageParts(messages);
|
|
341
|
-
if (!parts)
|
|
342
|
-
return;
|
|
343
|
-
agentSpan.setAttribute(GEN_AI.INPUT_MESSAGES, truncateAndSerialize([{ role: "user", parts }]));
|
|
344
|
-
}
|
|
345
|
-
catch {
|
|
346
|
-
/* never fail the application for telemetry */
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
756
|
function recordErrorOnSpan(span, err) {
|
|
350
757
|
const errorType = err instanceof Error ? err.constructor.name : typeof err;
|
|
351
758
|
const message = err instanceof Error ? err.message : String(err);
|