@struct-ai/sdk 0.3.17 → 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 +62 -17
- package/dist/commonjs/context.d.ts +19 -0
- package/dist/commonjs/context.js +58 -0
- package/dist/commonjs/core.js +104 -8
- package/dist/commonjs/events.d.ts +17 -6
- package/dist/commonjs/events.js +82 -59
- package/dist/commonjs/genai-content.d.ts +52 -0
- package/dist/commonjs/genai-content.js +143 -0
- package/dist/commonjs/instrument.d.ts +47 -0
- package/dist/commonjs/instrument.js +158 -0
- package/dist/commonjs/integrations/anthropic-content.js +18 -6
- package/dist/commonjs/integrations/anthropic.d.ts +6 -1
- package/dist/commonjs/integrations/anthropic.js +113 -125
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +3 -0
- package/dist/commonjs/integrations/langchain-callback.js +84 -6
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/openai-content.d.ts +34 -0
- package/dist/commonjs/integrations/openai-content.js +375 -0
- package/dist/commonjs/integrations/openai.d.ts +39 -0
- package/dist/commonjs/integrations/openai.js +305 -0
- package/dist/commonjs/semconv.d.ts +1 -0
- package/dist/commonjs/semconv.js +1 -0
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/context.d.ts +19 -0
- package/dist/esm/context.js +56 -0
- package/dist/esm/core.js +104 -8
- package/dist/esm/events.d.ts +17 -6
- package/dist/esm/events.js +82 -61
- package/dist/esm/genai-content.d.ts +52 -0
- package/dist/esm/genai-content.js +137 -0
- package/dist/esm/instrument.d.ts +47 -0
- package/dist/esm/instrument.js +155 -0
- package/dist/esm/integrations/anthropic-content.js +19 -7
- package/dist/esm/integrations/anthropic.d.ts +6 -1
- package/dist/esm/integrations/anthropic.js +112 -126
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +3 -0
- package/dist/esm/integrations/langchain-callback.js +85 -7
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/openai-content.d.ts +34 -0
- package/dist/esm/integrations/openai-content.js +360 -0
- package/dist/esm/integrations/openai.d.ts +39 -0
- package/dist/esm/integrations/openai.js +296 -0
- package/dist/esm/semconv.d.ts +1 -0
- package/dist/esm/semconv.js +1 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +8 -2
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
172
|
+
if (streaming) {
|
|
113
173
|
return wrapStream(original).call(this, params, opts);
|
|
114
174
|
}
|
|
115
175
|
const { tracer, sdk, logger } = patchCtx;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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,
|
|
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
|
-
|
|
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
|