@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.
Files changed (54) hide show
  1. package/README.md +84 -17
  2. package/dist/commonjs/context.d.ts +19 -0
  3. package/dist/commonjs/context.js +58 -0
  4. package/dist/commonjs/core.js +104 -8
  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 +6 -1
  13. package/dist/commonjs/integrations/anthropic.js +113 -125
  14. package/dist/commonjs/integrations/index.js +8 -0
  15. package/dist/commonjs/integrations/langchain-callback.d.ts +3 -0
  16. package/dist/commonjs/integrations/langchain-callback.js +84 -6
  17. package/dist/commonjs/integrations/langchain-content.js +1 -1
  18. package/dist/commonjs/integrations/openai-content.d.ts +34 -0
  19. package/dist/commonjs/integrations/openai-content.js +375 -0
  20. package/dist/commonjs/integrations/openai.d.ts +39 -0
  21. package/dist/commonjs/integrations/openai.js +305 -0
  22. package/dist/commonjs/semconv.d.ts +1 -0
  23. package/dist/commonjs/semconv.js +1 -0
  24. package/dist/commonjs/truncation.d.ts +29 -0
  25. package/dist/commonjs/truncation.js +184 -10
  26. package/dist/commonjs/version.d.ts +1 -1
  27. package/dist/commonjs/version.js +1 -1
  28. package/dist/esm/context.d.ts +19 -0
  29. package/dist/esm/context.js +56 -0
  30. package/dist/esm/core.js +104 -8
  31. package/dist/esm/events.d.ts +17 -6
  32. package/dist/esm/events.js +82 -61
  33. package/dist/esm/genai-content.d.ts +52 -0
  34. package/dist/esm/genai-content.js +137 -0
  35. package/dist/esm/instrument.d.ts +47 -0
  36. package/dist/esm/instrument.js +155 -0
  37. package/dist/esm/integrations/anthropic-content.js +19 -7
  38. package/dist/esm/integrations/anthropic.d.ts +6 -1
  39. package/dist/esm/integrations/anthropic.js +112 -126
  40. package/dist/esm/integrations/index.js +8 -0
  41. package/dist/esm/integrations/langchain-callback.d.ts +3 -0
  42. package/dist/esm/integrations/langchain-callback.js +85 -7
  43. package/dist/esm/integrations/langchain-content.js +1 -1
  44. package/dist/esm/integrations/openai-content.d.ts +34 -0
  45. package/dist/esm/integrations/openai-content.js +360 -0
  46. package/dist/esm/integrations/openai.d.ts +39 -0
  47. package/dist/esm/integrations/openai.js +296 -0
  48. package/dist/esm/semconv.d.ts +1 -0
  49. package/dist/esm/semconv.js +1 -0
  50. package/dist/esm/truncation.d.ts +29 -0
  51. package/dist/esm/truncation.js +182 -10
  52. package/dist/esm/version.d.ts +1 -1
  53. package/dist/esm/version.js +1 -1
  54. package/package.json +8 -2
@@ -0,0 +1,155 @@
1
+ import { SpanStatusCode, context as otelContext, trace, } from "@opentelemetry/api";
2
+ import { runWithStore, snapshotStore } from "./context.js";
3
+ import { safe } from "./core.js";
4
+ /**
5
+ * The single audited host boundary for provider `create()` instrumentation.
6
+ *
7
+ * STRUCTURAL GUARANTEE: `invoke()` (the host provider call) runs EXACTLY ONCE,
8
+ * and its return value / thrown error reaches the caller UNCHANGED, regardless
9
+ * of any telemetry failure. Nothing host-controllable — a customer's global
10
+ * OTel `ContextManager`, a hostile response/thenable, a broken tracer — is ever
11
+ * on the synchronous path that produces `invoke()`'s result:
12
+ *
13
+ * - span creation is `safe()`-guarded; on failure we run `invoke()`
14
+ * uninstrumented and return it;
15
+ * - all telemetry (request attrs/events, response attrs/events, error
16
+ * recording, span end) runs in `safe()` satellites that degrade to
17
+ * "no telemetry" on any throw;
18
+ * - `invoke()` itself is NOT wrapped in `otelContext.with(...)` or any other
19
+ * host-controllable operation — log-record→span linkage is carried by the
20
+ * explicit span passed to the emitters, not by ambient context.
21
+ *
22
+ * New providers supply only the telemetry callbacks, so they add no new way to
23
+ * break the host. Mirrors struct-sdk-python's generator sandwich in
24
+ * `_create_common` / `_wrap_create`.
25
+ */
26
+ export function instrumentCall(invoke, inst) {
27
+ const { tracer, spanName, spanKind, parentContext, sitePrefix, internalLogger } = inst;
28
+ let span;
29
+ let spanCtx;
30
+ safe(() => {
31
+ const parentCtx = parentContext();
32
+ span = tracer.startSpan(spanName, { kind: spanKind }, parentCtx);
33
+ spanCtx = trace.setSpan(parentCtx, span);
34
+ }, `${sitePrefix}.start_span`, internalLogger);
35
+ if (!span || !spanCtx) {
36
+ // Span creation failed partway (custom tracer / broken context manager /
37
+ // hostile Context.setValue). If the span WAS created before the failure,
38
+ // end it best-effort so it can't leak un-ended forever; then run the host
39
+ // call uninstrumented — never blocked by our telemetry.
40
+ const created = span;
41
+ if (created) {
42
+ safe(() => created.end(), `${sitePrefix}.span_end`, internalLogger);
43
+ }
44
+ return invoke();
45
+ }
46
+ const liveSpan = span;
47
+ const liveSpanCtx = spanCtx;
48
+ const storeSnapshot = snapshotStore();
49
+ safe(() => inst.onStart(liveSpan), `${sitePrefix}.set_request_attrs`, internalLogger);
50
+ // Run the host call with the chat span active in the ambient context, so a
51
+ // span the CUSTOMER's own tracer creates during the call (their HTTP/fetch
52
+ // instrumentation) nests under `chat` instead of becoming a sibling.
53
+ //
54
+ // GUARDED against a hostile global ContextManager: `otelContext.with` runs
55
+ // customer-controlled code that can misbehave in every direction — throw on
56
+ // context enter, throw on exit AFTER the callback ran, silently SKIP the
57
+ // callback, or call it MORE THAN ONCE. The `invoked` flag makes runInvoke
58
+ // idempotent (a double-calling manager can't duplicate the request) and the
59
+ // unconditional post-`with` check runs the call whenever the manager skipped
60
+ // it (silently or by throwing on enter). Exit-throw-after-call does NOT
61
+ // retry: `invoked` is already true. Net: the host call runs EXACTLY ONCE, no
62
+ // matter what the manager does.
63
+ let invoked = false;
64
+ let rawResult;
65
+ let threw = false;
66
+ let thrownErr;
67
+ const runInvoke = () => {
68
+ if (invoked)
69
+ return; // idempotent — hostile managers may call twice
70
+ invoked = true;
71
+ try {
72
+ rawResult = invoke();
73
+ }
74
+ catch (err) {
75
+ threw = true;
76
+ thrownErr = err;
77
+ }
78
+ };
79
+ try {
80
+ otelContext.with(liveSpanCtx, runInvoke);
81
+ }
82
+ catch {
83
+ /* enter/exit threw — the post-check below decides; never rethrow ours */
84
+ }
85
+ if (!invoked)
86
+ runInvoke(); // manager skipped the callback (silently or via throw)
87
+ if (threw) {
88
+ finalizeError(liveSpan, thrownErr, inst, storeSnapshot);
89
+ throw thrownErr; // the host's own error, unchanged
90
+ }
91
+ return observeResult(liveSpan, rawResult, inst, storeSnapshot);
92
+ }
93
+ function finalizeSuccess(span, result, inst, store) {
94
+ const { sitePrefix, internalLogger } = inst;
95
+ safe(() => runWithStore(store, () => {
96
+ safe(() => inst.onSuccess(span, result), `${sitePrefix}.set_response_attrs`, internalLogger);
97
+ safe(() => span.setStatus({ code: SpanStatusCode.OK }), `${sitePrefix}.set_ok_status`, internalLogger);
98
+ safe(() => span.end(), `${sitePrefix}.span_end`, internalLogger);
99
+ }), `${sitePrefix}.finalize`, internalLogger);
100
+ }
101
+ function finalizeError(span, err, inst, store) {
102
+ const { sitePrefix, internalLogger } = inst;
103
+ safe(() => runWithStore(store, () => {
104
+ safe(() => inst.onError(span, err), `${sitePrefix}.record_error`, internalLogger);
105
+ safe(() => span.end(), `${sitePrefix}.span_end_on_error`, internalLogger);
106
+ }), `${sitePrefix}.finalize_error`, internalLogger);
107
+ }
108
+ /**
109
+ * Observe the host call's return value for telemetry and hand it back UNCHANGED.
110
+ * A thenable (the provider's `APIPromise`) is observed via `.then(...)`, never
111
+ * replaced with a native `Promise` (which would strip `.withResponse()` etc.).
112
+ * The `.then` property read + registration are guarded so a hostile thenable
113
+ * degrades instead of throwing out of the (already-successful) host call.
114
+ */
115
+ function observeResult(span, rawResult, inst, store) {
116
+ const { sitePrefix, internalLogger } = inst;
117
+ // Exactly-once settlement, shared by BOTH observer callbacks and the
118
+ // hostile-thenable catch path: a custom PromiseLike may invoke onFulfilled
119
+ // twice, or onFulfilled then onRejected, or run callbacks synchronously and
120
+ // THEN throw from `.then` — any of which would otherwise double-finalize
121
+ // (duplicate choice events, duplicate pending tool-call ids, repeated
122
+ // span.end). Same invariant as runInvoke's `invoked` flag, applied to the
123
+ // observation side.
124
+ let settled = false;
125
+ const settleOnce = (fn) => {
126
+ if (settled)
127
+ return;
128
+ settled = true;
129
+ fn();
130
+ };
131
+ try {
132
+ const rt = rawResult;
133
+ if (rt && typeof rt.then === "function") {
134
+ rawResult.then((result) => settleOnce(() => finalizeSuccess(span, result, inst, store)),
135
+ // Observer branch only — does NOT rethrow; the original promise still
136
+ // rejects to the caller independently, so no unhandled rejection.
137
+ (err) => settleOnce(() => finalizeError(span, err, inst, store)));
138
+ return rawResult;
139
+ }
140
+ }
141
+ catch {
142
+ // Reading/invoking `.then` threw (hostile thenable). The host call already
143
+ // produced rawResult — degrade: close the span (unless a synchronously-run
144
+ // callback already settled it), hand rawResult back untouched.
145
+ settleOnce(() => {
146
+ safe(() => span.setStatus({ code: SpanStatusCode.OK }), `${sitePrefix}.set_ok_status`, internalLogger);
147
+ safe(() => span.end(), `${sitePrefix}.span_end`, internalLogger);
148
+ });
149
+ return rawResult;
150
+ }
151
+ // Non-thenable (a synchronous / mocked result): finalize inline.
152
+ settleOnce(() => finalizeSuccess(span, rawResult, inst, store));
153
+ return rawResult;
154
+ }
155
+ //# sourceMappingURL=instrument.js.map
@@ -1,4 +1,4 @@
1
- import { safeJsonStringify, truncateAndSerialize, truncateField, } from "../truncation.js";
1
+ import { serializeToolDefinitions, truncateAndSerialize, truncateField, } from "../truncation.js";
2
2
  import { ANTHROPIC_FINISH_REASON_MAP } from "../semconv.js";
3
3
  /**
4
4
  * Walk Anthropic response content blocks, return (name, id) pairs for tool_use blocks.
@@ -21,6 +21,21 @@ export function iterToolUses(blocks) {
21
21
  }
22
22
  return out;
23
23
  }
24
+ /**
25
+ * Read a boolean flag defensively. A hostile getter/Proxy trap must not
26
+ * poison the whole message capture — toInputMessages replaces the ENTIRE
27
+ * payload with "[]" on an escape from contentToParts — so an unreadable
28
+ * flag is simply absent while the rest of the block survives. Mirrors
29
+ * python _flag_is_true in anthropic.py — keep in lockstep.
30
+ */
31
+ function flagIsTrue(block, key) {
32
+ try {
33
+ return block[key] === true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
24
39
  /**
25
40
  * Convert arbitrary content (string | block[] | unknown) → GenAI parts.
26
41
  * Port of _content_to_parts from anthropic.py.
@@ -64,6 +79,8 @@ export function contentToParts(content) {
64
79
  if (block.tool_use_id)
65
80
  part.id = block.tool_use_id;
66
81
  part.response = block.content ?? "";
82
+ if (flagIsTrue(block, "is_error"))
83
+ part.is_error = true;
67
84
  parts.push(part);
68
85
  }
69
86
  else if (blockType === "thinking") {
@@ -145,12 +162,7 @@ export function toSystemInstructions(system) {
145
162
  }
146
163
  }
147
164
  export function safeJsonForTool(obj) {
148
- try {
149
- return truncateAndSerialize(obj);
150
- }
151
- catch {
152
- return safeJsonStringify(obj);
153
- }
165
+ return serializeToolDefinitions(obj);
154
166
  }
155
167
  /** Extract the last user message's parts for parent-span propagation. */
156
168
  export function lastUserMessageParts(messages) {
@@ -1,4 +1,4 @@
1
- import { type Tracer } from "@opentelemetry/api";
1
+ import { type Span, type Tracer } from "@opentelemetry/api";
2
2
  import type { Logger } from "@opentelemetry/api-logs";
3
3
  import { type StructSDK } from "../core.js";
4
4
  interface PatchContext {
@@ -22,6 +22,11 @@ export declare function patch(sdk: StructSDK): Promise<void>;
22
22
  export declare function unpatch(): Promise<void>;
23
23
  type CreateMethod = (this: unknown, params: CreateParams, opts?: unknown) => unknown;
24
24
  type StreamMethod = (this: unknown, params: CreateParams, opts?: unknown) => unknown;
25
+ declare function detectProvider(resource: unknown): string;
26
+ /** @internal */
27
+ export declare const _detectProviderForTest: typeof detectProvider;
28
+ /** @internal */
29
+ export declare const _setChatRequestAttrsForTest: (span: Span, params: CreateParams | undefined, sdk: StructSDK, logger: Logger | undefined, provider?: string) => void;
25
30
  export declare function wrapCreate(original: CreateMethod): CreateMethod;
26
31
  export declare function wrapStream(original: StreamMethod): StreamMethod;
27
32
  /** @internal */
@@ -1,5 +1,5 @@
1
1
  import { SpanKind, SpanStatusCode, context as otelContext, trace, } from "@opentelemetry/api";
2
- import { ensurePendingToolCallsSlot, getAgentSpan, getSessionId, isGenAiSuppressed, pushPendingToolCalls, runWithContext, 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,113 +99,95 @@ function restoreMethod(proto, methodName) {
97
99
  proto[methodName] = current[STRUCT_ORIGINAL];
98
100
  }
99
101
  }
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);
100
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);
106
154
  if (isGenAiSuppressed()) {
107
155
  // A framework layer owns this chat span — run the call, emit no span.
108
156
  return original.call(this, params, opts);
109
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
+ }
110
170
  // If params.stream is true, Messages.create delegates to the streaming path.
111
171
  // We still need to instrument the returned stream like we do in messages.stream.
112
- if (params && params.stream === true) {
172
+ if (streaming) {
113
173
  return wrapStream(original).call(this, params, opts);
114
174
  }
115
175
  const { tracer, sdk, logger } = patchCtx;
116
- const internalLogger = sdk.getInternalLogger();
117
- const model = params?.model ?? "unknown";
118
- // Span creation itself can fail (custom tracer, broken context). If it
119
- // does, fall through to calling `original` directly so the user's API
120
- // call always runs. Mirrors the Python `_wrap_create` fall-through.
121
- let span;
122
- let ctx;
123
- safe(() => {
124
- const parentCtx = childParentContext();
125
- span = tracer.startSpan(`chat ${model}`, { kind: SpanKind.CLIENT }, parentCtx);
126
- ctx = trace.setSpan(parentCtx, span);
127
- }, "anthropic.create.start_span", internalLogger);
128
- if (!span || !ctx) {
129
- return original.call(this, params, opts);
130
- }
131
- const liveSpan = span;
132
- const liveCtx = ctx;
133
- // Enter the span's context BEFORE emitting request-side log records so
134
- // `logger.emit({ context: otelContext.active() })` picks up the chat
135
- // span as the log's TraceId / SpanId. Without this the UI can't link
136
- // user/system message logs back to the chat span.
137
- return otelContext.with(liveCtx, () => {
138
- safe(() => setChatRequestAttrs(liveSpan, params, sdk, logger), "anthropic.create.set_request_attrs", internalLogger);
139
- let rawResult;
140
- try {
141
- rawResult = original.call(this, params, opts);
142
- }
143
- catch (err) {
144
- // Sync throw from the original call (e.g. a validation error thrown
145
- // before any request goes out) would otherwise leak the span — no
146
- // promise is ever created to drive the .then/.catch below.
147
- safe(() => recordErrorOnSpan(liveSpan, err), "anthropic.create.record_error", internalLogger);
148
- safe(() => liveSpan.end(), "anthropic.create.span_end_on_error", internalLogger);
149
- throw err;
150
- }
151
- // Observe the SDK's own return value (Anthropic's APIPromise) for
152
- // telemetry, then hand it BACK UNCHANGED — replacing it with a native
153
- // `Promise.resolve(...).then(...)` strips APIPromise methods
154
- // (.withResponse/.asResponse), breaking customer code only when
155
- // instrumentation is enabled. Our observer chain must not create an
156
- // unhandled rejection. (The prior code returned a native Promise here;
157
- // this is adjacent hardening of pre-existing behavior — the identity
158
- // hazard predates the streaming work.)
159
- //
160
- // Both the `.then` PROPERTY READ (`rt.then`) and the `.then(...)` CALL
161
- // that registers our observer run SYNCHRONOUSLY on the success path of
162
- // a customer's `create()` call, and were previously unguarded — a
163
- // hostile thenable (a `.then` accessor that throws on read, or a
164
- // `.then` function that throws synchronously when invoked) would turn
165
- // a SUCCESSFUL Anthropic request into a synchronous exception thrown
166
- // out of `wrappedCreate`. Wrap the read + registration so a broken
167
- // thenable degrades instead of crashing the host: close the span with
168
- // no telemetry and hand back rawResult UNCHANGED — never
169
- // `Promise.resolve(...)`, which would also strip APIPromise
170
- // identity/methods.
171
- try {
172
- const rt = rawResult;
173
- if (rt && typeof rt.then === "function") {
174
- rawResult.then((result) => {
175
- safe(() => setChatResponseAttrs(liveSpan, sdk, result, logger), "anthropic.create.set_response_attrs", internalLogger);
176
- safe(() => liveSpan.setStatus({ code: SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
177
- safe(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
178
- }, (err) => {
179
- safe(() => recordErrorOnSpan(liveSpan, err), "anthropic.create.record_error", internalLogger);
180
- safe(() => liveSpan.end(), "anthropic.create.span_end_on_error", internalLogger);
181
- // Observer branch only — do NOT rethrow; the original APIPromise
182
- // still rejects to the customer independently.
183
- });
184
- return rawResult;
185
- }
186
- }
187
- catch {
188
- // Reading `.then` or invoking it threw (hostile thenable). The
189
- // underlying call already succeeded (rawResult exists) — this is an
190
- // instrumentation-only failure, not an API error. Degrade like the
191
- // other host-crash guards in this file (e.g. wrapStream's dispatch
192
- // region below): close the span with no telemetry, hand back
193
- // rawResult untouched.
194
- safe(() => liveSpan.setStatus({ code: SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
195
- safe(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
196
- return rawResult;
197
- }
198
- // Non-thenable return (a synchronous / mocked result, or any SDK shape
199
- // that isn't a promise): run the response telemetry inline on it, close
200
- // the span, and hand it back untouched — matching the prior
201
- // `Promise.resolve(...).then(...)` behavior, which unified thenable and
202
- // non-thenable returns.
203
- safe(() => setChatResponseAttrs(liveSpan, sdk, rawResult, logger), "anthropic.create.set_response_attrs", internalLogger);
204
- safe(() => liveSpan.setStatus({ code: SpanStatusCode.OK }), "anthropic.create.set_ok_status", internalLogger);
205
- safe(() => liveSpan.end(), "anthropic.create.span_end", internalLogger);
206
- return rawResult;
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),
207
191
  });
208
192
  };
209
193
  }
@@ -213,13 +197,26 @@ export function wrapStream(original) {
213
197
  if (!patchCtx) {
214
198
  return original.call(this, params, opts);
215
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);
216
205
  if (isGenAiSuppressed()) {
217
206
  // A framework layer owns this chat span — run the call, emit no span.
218
207
  return original.call(this, params, opts);
219
208
  }
220
209
  const { tracer, sdk, logger } = patchCtx;
221
210
  const internalLogger = sdk.getInternalLogger();
222
- 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
+ }
223
220
  // Span creation itself can fail. If it does, fall through to calling
224
221
  // `original` directly so the user's stream call always runs. Mirrors
225
222
  // the Python `_wrap_stream` fall-through pattern.
@@ -246,7 +243,7 @@ export function wrapStream(original) {
246
243
  safe(() => recordErrorOnSpan(liveSpan, err), "anthropic.stream.record_error", internalLogger);
247
244
  }
248
245
  else if (result) {
249
- 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);
250
247
  safe(() => liveSpan.setStatus({ code: SpanStatusCode.OK }), "anthropic.stream.set_ok_status", internalLogger);
251
248
  }
252
249
  else {
@@ -285,10 +282,14 @@ export function wrapStream(original) {
285
282
  // runs with a clean (unsuppressed) ALS store.
286
283
  let stream;
287
284
  try {
288
- stream = otelContext.with(liveCtx, () => {
289
- safe(() => setChatRequestAttrs(liveSpan, params, sdk, logger), "anthropic.stream.set_request_attrs", internalLogger);
290
- return runWithContext({ suppressGenAi: true }, () => original.call(this, params, opts));
291
- });
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));
292
293
  }
293
294
  catch (err) {
294
295
  // Sync throw from the original call would otherwise leak the span —
@@ -658,9 +659,9 @@ export function wrapStream(original) {
658
659
  }
659
660
  };
660
661
  }
661
- function setChatRequestAttrs(span, params, sdk, logger) {
662
+ function setChatRequestAttrs(span, params, sdk, logger, provider = "anthropic") {
662
663
  span.setAttribute(GEN_AI.OPERATION_NAME, "chat");
663
- span.setAttribute(GEN_AI.PROVIDER_NAME, "anthropic");
664
+ span.setAttribute(GEN_AI.PROVIDER_NAME, provider);
664
665
  const model = params?.model ?? "unknown";
665
666
  span.setAttribute(GEN_AI.REQUEST_MODEL, model);
666
667
  const sessionId = getSessionId();
@@ -684,10 +685,15 @@ function setChatRequestAttrs(span, params, sdk, logger) {
684
685
  }
685
686
  if (Array.isArray(params?.messages)) {
686
687
  span.setAttribute(STRUCT.INPUT_MESSAGE_COUNT, params.messages.length);
687
- 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
+ }
688
694
  }
689
695
  if (sdk.emitEvents && logger && Array.isArray(params?.messages)) {
690
- emitAnthropicMessageEvents(logger, params.messages, params.system);
696
+ emitAnthropicMessageEvents(logger, params.messages, params.system, span, provider);
691
697
  }
692
698
  if (sdk.emitSpanContent) {
693
699
  if (Array.isArray(params?.messages)) {
@@ -701,7 +707,7 @@ function setChatRequestAttrs(span, params, sdk, logger) {
701
707
  }
702
708
  }
703
709
  }
704
- function setChatResponseAttrs(span, sdk, response, logger) {
710
+ function setChatResponseAttrs(span, sdk, response, logger, provider = "anthropic") {
705
711
  const usage = response.usage;
706
712
  if (usage) {
707
713
  const inputTokens = usage.input_tokens ?? 0;
@@ -731,7 +737,7 @@ function setChatResponseAttrs(span, sdk, response, logger) {
731
737
  // Always runs — populates pending queue regardless of content-capture mode.
732
738
  recordPendingToolCalls(response.content);
733
739
  if (sdk.emitEvents && logger) {
734
- emitAnthropicChoiceEvent(logger, response.content, response.stop_reason);
740
+ emitAnthropicChoiceEvent(logger, response.content, response.stop_reason, span, provider);
735
741
  }
736
742
  if (sdk.emitSpanContent) {
737
743
  span.setAttribute(GEN_AI.OUTPUT_MESSAGES, toOutputMessages(response.content, response.stop_reason));
@@ -747,26 +753,6 @@ function recordPendingToolCalls(contentBlocks) {
747
753
  ensurePendingToolCallsSlot();
748
754
  pushPendingToolCalls(pairs);
749
755
  }
750
- function propagateUserPromptToParent(messages) {
751
- try {
752
- const agentSpan = getAgentSpan();
753
- if (!agentSpan)
754
- return;
755
- // The SDK span type doesn't expose `attributes` — we use a brand check on
756
- // the SDK's ReadableSpan-like shape.
757
- const agentAttrs = agentSpan
758
- .attributes;
759
- if (agentAttrs && agentAttrs[GEN_AI.INPUT_MESSAGES])
760
- return;
761
- const parts = lastUserMessageParts(messages);
762
- if (!parts)
763
- return;
764
- agentSpan.setAttribute(GEN_AI.INPUT_MESSAGES, truncateAndSerialize([{ role: "user", parts }]));
765
- }
766
- catch {
767
- /* never fail the application for telemetry */
768
- }
769
- }
770
756
  function recordErrorOnSpan(span, err) {
771
757
  const errorType = err instanceof Error ? err.constructor.name : typeof err;
772
758
  const message = err instanceof Error ? err.message : String(err);
@@ -11,6 +11,14 @@ const INTEGRATIONS = [
11
11
  await mod.patch(sdk);
12
12
  },
13
13
  },
14
+ {
15
+ name: "openai",
16
+ probe: () => import("openai"),
17
+ apply: async (sdk) => {
18
+ const mod = await import("./openai.js");
19
+ await mod.patch(sdk);
20
+ },
21
+ },
14
22
  {
15
23
  name: "langchain",
16
24
  probe: () => import("@langchain/core/language_models/chat_models"),
@@ -282,5 +282,8 @@ export declare class StructCallbackHandler {
282
282
  private resolveAgentSessionId;
283
283
  private propagateUserPrompt;
284
284
  }
285
+ declare function detectProviderFromSerialized(llm: Serialized): string;
286
+ /** @internal */
287
+ export declare const _detectProviderFromSerializedForTest: typeof detectProviderFromSerialized;
285
288
  export {};
286
289
  //# sourceMappingURL=langchain-callback.d.ts.map