@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.
Files changed (58) hide show
  1. package/README.md +101 -16
  2. package/dist/commonjs/context.d.ts +45 -0
  3. package/dist/commonjs/context.js +78 -1
  4. package/dist/commonjs/core.js +184 -29
  5. package/dist/commonjs/events.d.ts +17 -6
  6. package/dist/commonjs/events.js +82 -59
  7. package/dist/commonjs/genai-content.d.ts +52 -0
  8. package/dist/commonjs/genai-content.js +143 -0
  9. package/dist/commonjs/instrument.d.ts +47 -0
  10. package/dist/commonjs/instrument.js +158 -0
  11. package/dist/commonjs/integrations/anthropic-content.js +18 -6
  12. package/dist/commonjs/integrations/anthropic.d.ts +8 -1
  13. package/dist/commonjs/integrations/anthropic.js +515 -104
  14. package/dist/commonjs/integrations/index.js +8 -0
  15. package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
  16. package/dist/commonjs/integrations/langchain-callback.js +754 -87
  17. package/dist/commonjs/integrations/langchain-content.js +1 -1
  18. package/dist/commonjs/integrations/langchain.d.ts +3 -0
  19. package/dist/commonjs/integrations/langchain.js +353 -7
  20. package/dist/commonjs/integrations/openai-content.d.ts +34 -0
  21. package/dist/commonjs/integrations/openai-content.js +375 -0
  22. package/dist/commonjs/integrations/openai.d.ts +39 -0
  23. package/dist/commonjs/integrations/openai.js +305 -0
  24. package/dist/commonjs/semconv.d.ts +12 -0
  25. package/dist/commonjs/semconv.js +13 -1
  26. package/dist/commonjs/truncation.d.ts +29 -0
  27. package/dist/commonjs/truncation.js +184 -10
  28. package/dist/commonjs/version.d.ts +2 -0
  29. package/dist/commonjs/version.js +6 -0
  30. package/dist/esm/context.d.ts +45 -0
  31. package/dist/esm/context.js +74 -1
  32. package/dist/esm/core.js +185 -30
  33. package/dist/esm/events.d.ts +17 -6
  34. package/dist/esm/events.js +82 -61
  35. package/dist/esm/genai-content.d.ts +52 -0
  36. package/dist/esm/genai-content.js +137 -0
  37. package/dist/esm/instrument.d.ts +47 -0
  38. package/dist/esm/instrument.js +155 -0
  39. package/dist/esm/integrations/anthropic-content.js +19 -7
  40. package/dist/esm/integrations/anthropic.d.ts +8 -1
  41. package/dist/esm/integrations/anthropic.js +514 -107
  42. package/dist/esm/integrations/index.js +8 -0
  43. package/dist/esm/integrations/langchain-callback.d.ts +182 -27
  44. package/dist/esm/integrations/langchain-callback.js +756 -89
  45. package/dist/esm/integrations/langchain-content.js +1 -1
  46. package/dist/esm/integrations/langchain.d.ts +3 -0
  47. package/dist/esm/integrations/langchain.js +352 -7
  48. package/dist/esm/integrations/openai-content.d.ts +34 -0
  49. package/dist/esm/integrations/openai-content.js +360 -0
  50. package/dist/esm/integrations/openai.d.ts +39 -0
  51. package/dist/esm/integrations/openai.js +296 -0
  52. package/dist/esm/semconv.d.ts +12 -0
  53. package/dist/esm/semconv.js +12 -0
  54. package/dist/esm/truncation.d.ts +29 -0
  55. package/dist/esm/truncation.js +182 -10
  56. package/dist/esm/version.d.ts +2 -0
  57. package/dist/esm/version.js +3 -0
  58. 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
- function wrapCreate(original) {
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 (params && params.stream === true) {
172
+ if (streaming) {
109
173
  return wrapStream(original).call(this, params, opts);
110
174
  }
111
175
  const { tracer, sdk, logger } = patchCtx;
112
- const internalLogger = sdk.getInternalLogger();
113
- const model = params?.model ?? "unknown";
114
- // Span creation itself can fail (custom tracer, broken context). If it
115
- // does, fall through to calling `original` directly so the user's API
116
- // call always runs. Mirrors the Python `_wrap_create` fall-through.
117
- let span;
118
- let ctx;
119
- safe(() => {
120
- const parentCtx = childParentContext();
121
- span = tracer.startSpan(`chat ${model}`, { kind: SpanKind.CLIENT }, parentCtx);
122
- ctx = trace.setSpan(parentCtx, span);
123
- }, "anthropic.create.start_span", internalLogger);
124
- if (!span || !ctx) {
125
- return original.call(this, params, opts);
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
- const model = params?.model ?? "unknown";
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
- const ee = stream;
202
- if (ee && typeof ee.on === "function") {
203
- ee.on("finalMessage", (msg) => {
204
- finalize(msg, undefined);
205
- });
206
- ee.on("error", (err) => {
207
- finalize(undefined, err);
208
- });
209
- // If the stream is created and never iterated / finalized, at least
210
- // don't hang on test exit — "end" fires after iteration completes.
211
- ee.on("end", () => {
212
- // Only fires if no finalMessage / error fired first (rare path).
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
- // Wrap the async iterator so each .next() runs inside the captured ALS store.
217
- if (ee && typeof ee[Symbol.asyncIterator] === "function") {
218
- const originalIter = ee[Symbol.asyncIterator].bind(ee);
219
- ee[Symbol.asyncIterator] = () => {
220
- const inner = originalIter();
221
- const wrapped = {
222
- next() {
223
- return runWithStore(storeSnapshot, () => inner.next());
224
- },
225
- return(value) {
226
- return runWithStore(storeSnapshot, () => inner.return ? inner.return(value) : Promise.resolve({ value, done: true }));
227
- },
228
- throw(err) {
229
- return runWithStore(storeSnapshot, () => inner.throw
230
- ? inner.throw(err)
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
- return wrapped;
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, "anthropic");
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
- propagateUserPromptToParent(params.messages);
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);