@struct-ai/sdk 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -16
- package/dist/commonjs/context.d.ts +45 -0
- package/dist/commonjs/context.js +78 -1
- package/dist/commonjs/core.js +184 -29
- package/dist/commonjs/events.d.ts +17 -6
- package/dist/commonjs/events.js +82 -59
- package/dist/commonjs/genai-content.d.ts +52 -0
- package/dist/commonjs/genai-content.js +143 -0
- package/dist/commonjs/instrument.d.ts +47 -0
- package/dist/commonjs/instrument.js +158 -0
- package/dist/commonjs/integrations/anthropic-content.js +18 -6
- package/dist/commonjs/integrations/anthropic.d.ts +8 -1
- package/dist/commonjs/integrations/anthropic.js +515 -104
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
- package/dist/commonjs/integrations/langchain-callback.js +754 -87
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/langchain.d.ts +3 -0
- package/dist/commonjs/integrations/langchain.js +353 -7
- package/dist/commonjs/integrations/openai-content.d.ts +34 -0
- package/dist/commonjs/integrations/openai-content.js +375 -0
- package/dist/commonjs/integrations/openai.d.ts +39 -0
- package/dist/commonjs/integrations/openai.js +305 -0
- package/dist/commonjs/semconv.d.ts +12 -0
- package/dist/commonjs/semconv.js +13 -1
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +2 -0
- package/dist/commonjs/version.js +6 -0
- package/dist/esm/context.d.ts +45 -0
- package/dist/esm/context.js +74 -1
- package/dist/esm/core.js +185 -30
- package/dist/esm/events.d.ts +17 -6
- package/dist/esm/events.js +82 -61
- package/dist/esm/genai-content.d.ts +52 -0
- package/dist/esm/genai-content.js +137 -0
- package/dist/esm/instrument.d.ts +47 -0
- package/dist/esm/instrument.js +155 -0
- package/dist/esm/integrations/anthropic-content.js +19 -7
- package/dist/esm/integrations/anthropic.d.ts +8 -1
- package/dist/esm/integrations/anthropic.js +514 -107
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +182 -27
- package/dist/esm/integrations/langchain-callback.js +756 -89
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/langchain.d.ts +3 -0
- package/dist/esm/integrations/langchain.js +352 -7
- package/dist/esm/integrations/openai-content.d.ts +34 -0
- package/dist/esm/integrations/openai-content.js +360 -0
- package/dist/esm/integrations/openai.d.ts +39 -0
- package/dist/esm/integrations/openai.js +296 -0
- package/dist/esm/semconv.d.ts +12 -0
- package/dist/esm/semconv.js +12 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +3 -0
- package/package.json +11 -3
package/dist/esm/events.js
CHANGED
|
@@ -1,37 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { safeJsonStringify, truncateParts } from "./truncation.js";
|
|
6
|
-
import { contentToParts, } from "./integrations/anthropic-content.js";
|
|
7
|
-
/**
|
|
8
|
-
* Emit a LogRecord with the active span context linked.
|
|
9
|
-
*
|
|
10
|
-
* Follows the OTel logs data model convention:
|
|
11
|
-
* - `body` (log record body) = event tag string (human-readable signal)
|
|
12
|
-
* - `attributes.body` (log record attribute) = JSON-serialised payload
|
|
13
|
-
*/
|
|
14
|
-
function emitLogRecord({ logger, eventName, payload, extraAttrs = {}, }) {
|
|
15
|
-
const sessionId = getSessionId();
|
|
16
|
-
const attributes = {
|
|
17
|
-
[EVENT_NAME]: eventName,
|
|
18
|
-
body: payload,
|
|
19
|
-
...extraAttrs,
|
|
20
|
-
};
|
|
21
|
-
if (sessionId)
|
|
22
|
-
attributes[GEN_AI.CONVERSATION_ID] = sessionId;
|
|
23
|
-
logger.emit({
|
|
24
|
-
body: eventName,
|
|
25
|
-
severityNumber: SeverityNumber.INFO,
|
|
26
|
-
attributes,
|
|
27
|
-
context: otelContext.active(),
|
|
28
|
-
});
|
|
29
|
-
}
|
|
1
|
+
import { emitChoiceEvent, emitMessageEvent, } from "./genai-content.js";
|
|
2
|
+
import { ANTHROPIC_FINISH_REASON_MAP, EVENT_NAMES, ROLE_TO_EVENT_NAME, } from "./semconv.js";
|
|
3
|
+
import { contentToParts } from "./integrations/anthropic-content.js";
|
|
4
|
+
import { inputItemToEvent, mapChoiceFinishReason, normalizeInput, outputItemToChoiceParts, } from "./integrations/openai-content.js";
|
|
30
5
|
/**
|
|
31
6
|
* Emit per-message log events for an Anthropic messages.create() call.
|
|
32
|
-
*
|
|
7
|
+
* Delegates the LogRecord wiring to the shared genai-content emitters; this
|
|
8
|
+
* function is only the Anthropic message → parts mapping + ordering.
|
|
33
9
|
*/
|
|
34
|
-
export function emitAnthropicMessageEvents(logger, messages, system) {
|
|
10
|
+
export function emitAnthropicMessageEvents(logger, messages, system, span, provider = "anthropic") {
|
|
35
11
|
if (!Array.isArray(messages))
|
|
36
12
|
return;
|
|
37
13
|
let msgIndex = 0;
|
|
@@ -41,17 +17,14 @@ export function emitAnthropicMessageEvents(logger, messages, system) {
|
|
|
41
17
|
: Array.isArray(system)
|
|
42
18
|
? contentToParts(system)
|
|
43
19
|
: [{ type: "text", content: String(system) }];
|
|
44
|
-
|
|
20
|
+
emitMessageEvent({
|
|
45
21
|
logger,
|
|
22
|
+
role: "system",
|
|
23
|
+
parts,
|
|
46
24
|
eventName: EVENT_NAMES.SYSTEM_MESSAGE,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}),
|
|
51
|
-
extraAttrs: {
|
|
52
|
-
[GEN_AI.SYSTEM]: "anthropic",
|
|
53
|
-
[GEN_AI.MESSAGE_INDEX]: msgIndex,
|
|
54
|
-
},
|
|
25
|
+
provider,
|
|
26
|
+
messageIndex: msgIndex,
|
|
27
|
+
span,
|
|
55
28
|
});
|
|
56
29
|
msgIndex++;
|
|
57
30
|
}
|
|
@@ -62,38 +35,86 @@ export function emitAnthropicMessageEvents(logger, messages, system) {
|
|
|
62
35
|
const role = typeof m.role === "string" ? m.role : "user";
|
|
63
36
|
const parts = contentToParts(m.content);
|
|
64
37
|
const eventName = ROLE_TO_EVENT_NAME[role] ?? `gen_ai.${role}.message`;
|
|
65
|
-
|
|
38
|
+
emitMessageEvent({
|
|
66
39
|
logger,
|
|
40
|
+
role,
|
|
41
|
+
parts,
|
|
67
42
|
eventName,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
[GEN_AI.MESSAGE_INDEX]: msgIndex,
|
|
72
|
-
},
|
|
43
|
+
provider,
|
|
44
|
+
messageIndex: msgIndex,
|
|
45
|
+
span,
|
|
73
46
|
});
|
|
74
47
|
msgIndex++;
|
|
75
48
|
}
|
|
76
49
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
* Port of _emit_choice_event from anthropic.py.
|
|
80
|
-
*/
|
|
81
|
-
export function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason) {
|
|
50
|
+
/** Emit a gen_ai.choice LogRecord for an Anthropic response. */
|
|
51
|
+
export function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason, span, provider = "anthropic") {
|
|
82
52
|
const parts = contentToParts(contentBlocks);
|
|
83
53
|
const mappedReason = (stopReason && (ANTHROPIC_FINISH_REASON_MAP[stopReason] ?? stopReason)) ||
|
|
84
54
|
"stop";
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
55
|
+
emitChoiceEvent({
|
|
56
|
+
logger,
|
|
57
|
+
parts,
|
|
58
|
+
finishReason: mappedReason,
|
|
59
|
+
provider,
|
|
60
|
+
span,
|
|
89
61
|
});
|
|
90
|
-
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Emit per-message log events for an OpenAI responses.create() call.
|
|
65
|
+
* `instructions` (the Responses system prompt) is emitted FIRST at index 0.
|
|
66
|
+
* Delegates LogRecord wiring to the shared emitters; only the Responses
|
|
67
|
+
* item → event mapping + ordering lives here.
|
|
68
|
+
*/
|
|
69
|
+
export function emitOpenAIInputMessageEvents(logger, input, instructions, span, provider = "openai") {
|
|
70
|
+
let msgIndex = 0;
|
|
71
|
+
if (instructions) {
|
|
72
|
+
const parts = typeof instructions === "string"
|
|
73
|
+
? [{ type: "text", content: instructions }]
|
|
74
|
+
: [{ type: "text", content: String(instructions) }];
|
|
75
|
+
emitMessageEvent({
|
|
76
|
+
logger,
|
|
77
|
+
role: "system",
|
|
78
|
+
parts,
|
|
79
|
+
eventName: EVENT_NAMES.SYSTEM_MESSAGE,
|
|
80
|
+
provider,
|
|
81
|
+
messageIndex: msgIndex,
|
|
82
|
+
span,
|
|
83
|
+
});
|
|
84
|
+
msgIndex++;
|
|
85
|
+
}
|
|
86
|
+
for (const item of normalizeInput(input)) {
|
|
87
|
+
const mapped = inputItemToEvent(item);
|
|
88
|
+
if (!mapped)
|
|
89
|
+
continue;
|
|
90
|
+
const [eventName, role, parts] = mapped;
|
|
91
|
+
emitMessageEvent({
|
|
92
|
+
logger,
|
|
93
|
+
role,
|
|
94
|
+
parts,
|
|
95
|
+
eventName,
|
|
96
|
+
provider,
|
|
97
|
+
messageIndex: msgIndex,
|
|
98
|
+
span,
|
|
99
|
+
});
|
|
100
|
+
msgIndex++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Emit the gen_ai.choice LogRecord from an OpenAI response.output.
|
|
105
|
+
* `finishReason` is mapped inside this emitter using mapChoiceFinishReason.
|
|
106
|
+
*/
|
|
107
|
+
export function emitOpenAIChoiceEvent(logger, output, finishReason, span, provider = "openai") {
|
|
108
|
+
const parts = [];
|
|
109
|
+
for (const item of Array.isArray(output) ? output : []) {
|
|
110
|
+
parts.push(...outputItemToChoiceParts(item));
|
|
111
|
+
}
|
|
112
|
+
emitChoiceEvent({
|
|
91
113
|
logger,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
},
|
|
114
|
+
parts,
|
|
115
|
+
finishReason: mapChoiceFinishReason(finishReason),
|
|
116
|
+
provider,
|
|
117
|
+
span,
|
|
97
118
|
});
|
|
98
119
|
}
|
|
99
120
|
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Span } from "@opentelemetry/api";
|
|
2
|
+
import { type Logger } from "@opentelemetry/api-logs";
|
|
3
|
+
/** Part in the GenAI spec format (provider-agnostic, already mapped). */
|
|
4
|
+
export type Part = Record<string, unknown>;
|
|
5
|
+
/** Emit ONE per-message LogRecord from already-built spec parts. */
|
|
6
|
+
export declare function emitMessageEvent(opts: {
|
|
7
|
+
logger: Logger;
|
|
8
|
+
role: string;
|
|
9
|
+
parts: Part[];
|
|
10
|
+
eventName: string;
|
|
11
|
+
provider: string;
|
|
12
|
+
messageIndex: number;
|
|
13
|
+
span?: Span;
|
|
14
|
+
}): void;
|
|
15
|
+
/**
|
|
16
|
+
* Emit the `gen_ai.choice` LogRecord. `finishReason` is already spec-mapped by
|
|
17
|
+
* the caller (mapping is provider-specific). Omits `gen_ai.message.index`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function emitChoiceEvent(opts: {
|
|
20
|
+
logger: Logger;
|
|
21
|
+
parts: Part[];
|
|
22
|
+
finishReason: string;
|
|
23
|
+
provider: string;
|
|
24
|
+
span?: Span;
|
|
25
|
+
}): void;
|
|
26
|
+
/**
|
|
27
|
+
* Stamp the last user message on the parent invoke_agent span (write-once).
|
|
28
|
+
* Provider-agnostic: the caller extracts the last user message's spec parts
|
|
29
|
+
* (Anthropic message blocks vs Responses items differ); this only stamps them.
|
|
30
|
+
*/
|
|
31
|
+
export declare function propagateUserPromptToParent(lastUserParts: Part[] | undefined): void;
|
|
32
|
+
export type ProviderClassNameRule = readonly [ReadonlySet<string>, string];
|
|
33
|
+
export type ProviderHostRule = readonly [(host: string) => boolean, string];
|
|
34
|
+
/**
|
|
35
|
+
* Best-knowledge `gen_ai.provider.name` from a bound resource. TS twin of
|
|
36
|
+
* python `_genai_content.detect_provider_from_resource`.
|
|
37
|
+
*
|
|
38
|
+
* The platform client flavors (@anthropic-ai/bedrock-sdk, /vertex-sdk,
|
|
39
|
+
* AzureOpenAI) reuse the same resource prototypes as the first-party clients,
|
|
40
|
+
* so the platform is read at call time from the resource's owning client:
|
|
41
|
+
* EXACT constructor names up the prototype chain first (exact, not substring
|
|
42
|
+
* — a class named `NotAzureOpenAI` must not match; subclasses match via their
|
|
43
|
+
* inherited base's name), then per-platform `baseURL` host predicates matching
|
|
44
|
+
* only official endpoint shapes.
|
|
45
|
+
*
|
|
46
|
+
* Takes the RESOURCE: the `_client` property access is a host-boundary read (a
|
|
47
|
+
* proxied resource can throw from its getter), so it happens inside this
|
|
48
|
+
* function's guard. Falls back whenever routing is not positively detectable;
|
|
49
|
+
* never throws.
|
|
50
|
+
*/
|
|
51
|
+
export declare function detectProviderFromResource(resource: unknown, classNameRules: readonly ProviderClassNameRule[], hostRules: readonly ProviderHostRule[], fallback: string): string;
|
|
52
|
+
//# sourceMappingURL=genai-content.d.ts.map
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { context as otelContext, trace } from "@opentelemetry/api";
|
|
2
|
+
import { SeverityNumber, } from "@opentelemetry/api-logs";
|
|
3
|
+
import { getAgentSpan, getSessionId } from "./context.js";
|
|
4
|
+
import { EVENT_NAME, EVENT_NAMES, GEN_AI } from "./semconv.js";
|
|
5
|
+
import { safeJsonStringify, truncateAndSerialize, truncateParts, } from "./truncation.js";
|
|
6
|
+
/**
|
|
7
|
+
* Low-level LogRecord emitter — the single home for the OTel logs data-model
|
|
8
|
+
* wiring both providers share:
|
|
9
|
+
* - `body` (log record body) = event-tag string
|
|
10
|
+
* - `attributes.body` = JSON-serialised structured payload
|
|
11
|
+
* - `gen_ai.provider.name` always stamped; `gen_ai.conversation.id` when a
|
|
12
|
+
* session is active.
|
|
13
|
+
*
|
|
14
|
+
* Span-context is taken from the EXPLICIT `span` when given. This matters on
|
|
15
|
+
* async resolution paths (provider `.then` continuations): OTel context does
|
|
16
|
+
* NOT auto-propagate there (the SDK installs no global context manager), so
|
|
17
|
+
* `otelContext.active()` would drop the chat span and mis-link the record.
|
|
18
|
+
* Passing the span explicitly pins the linkage. (ALS-derived values like the
|
|
19
|
+
* session id DO propagate via async_hooks, so `getSessionId()` stays correct.)
|
|
20
|
+
*/
|
|
21
|
+
function emitLogRecord(logger, eventName, payload, extraAttrs, span) {
|
|
22
|
+
const sessionId = getSessionId();
|
|
23
|
+
const attributes = {
|
|
24
|
+
[EVENT_NAME]: eventName,
|
|
25
|
+
body: payload,
|
|
26
|
+
...extraAttrs,
|
|
27
|
+
};
|
|
28
|
+
if (sessionId)
|
|
29
|
+
attributes[GEN_AI.CONVERSATION_ID] = sessionId;
|
|
30
|
+
const ctx = span
|
|
31
|
+
? trace.setSpan(otelContext.active(), span)
|
|
32
|
+
: otelContext.active();
|
|
33
|
+
logger.emit({
|
|
34
|
+
body: eventName,
|
|
35
|
+
severityNumber: SeverityNumber.INFO,
|
|
36
|
+
attributes,
|
|
37
|
+
context: ctx,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Emit ONE per-message LogRecord from already-built spec parts. */
|
|
41
|
+
export function emitMessageEvent(opts) {
|
|
42
|
+
const { logger, role, parts, eventName, provider, messageIndex, span } = opts;
|
|
43
|
+
emitLogRecord(logger, eventName, safeJsonStringify({ role, parts: truncateParts(parts) }), {
|
|
44
|
+
[GEN_AI.PROVIDER_NAME]: provider,
|
|
45
|
+
[GEN_AI.MESSAGE_INDEX]: messageIndex,
|
|
46
|
+
}, span);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Emit the `gen_ai.choice` LogRecord. `finishReason` is already spec-mapped by
|
|
50
|
+
* the caller (mapping is provider-specific). Omits `gen_ai.message.index`.
|
|
51
|
+
*/
|
|
52
|
+
export function emitChoiceEvent(opts) {
|
|
53
|
+
const { logger, parts, finishReason, provider, span } = opts;
|
|
54
|
+
const payload = safeJsonStringify({
|
|
55
|
+
index: 0,
|
|
56
|
+
finish_reason: finishReason || "stop",
|
|
57
|
+
message: { role: "assistant", parts: truncateParts(parts) },
|
|
58
|
+
});
|
|
59
|
+
emitLogRecord(logger, EVENT_NAMES.CHOICE, payload, { [GEN_AI.PROVIDER_NAME]: provider }, span);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Stamp the last user message on the parent invoke_agent span (write-once).
|
|
63
|
+
* Provider-agnostic: the caller extracts the last user message's spec parts
|
|
64
|
+
* (Anthropic message blocks vs Responses items differ); this only stamps them.
|
|
65
|
+
*/
|
|
66
|
+
export function propagateUserPromptToParent(lastUserParts) {
|
|
67
|
+
try {
|
|
68
|
+
// Skip when the last user message has no parts (null/empty content). This
|
|
69
|
+
// matches Python's `_genai_content.propagate_user_prompt_to_parent`
|
|
70
|
+
// (`if not last_user_parts: return`) — an intentional parity-aligned delta
|
|
71
|
+
// from the old Anthropic-only helper, which stamped an empty stub here.
|
|
72
|
+
if (!lastUserParts || lastUserParts.length === 0)
|
|
73
|
+
return;
|
|
74
|
+
const agentSpan = getAgentSpan();
|
|
75
|
+
if (!agentSpan)
|
|
76
|
+
return;
|
|
77
|
+
// The SDK span type doesn't expose `attributes` — brand-check the
|
|
78
|
+
// ReadableSpan-like shape (same as the prior anthropic.ts inline helper).
|
|
79
|
+
const agentAttrs = agentSpan.attributes;
|
|
80
|
+
if (agentAttrs && agentAttrs[GEN_AI.INPUT_MESSAGES])
|
|
81
|
+
return; // write-once
|
|
82
|
+
agentSpan.setAttribute(GEN_AI.INPUT_MESSAGES, truncateAndSerialize([{ role: "user", parts: lastUserParts }]));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* never fail the application for telemetry */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Best-knowledge `gen_ai.provider.name` from a bound resource. TS twin of
|
|
90
|
+
* python `_genai_content.detect_provider_from_resource`.
|
|
91
|
+
*
|
|
92
|
+
* The platform client flavors (@anthropic-ai/bedrock-sdk, /vertex-sdk,
|
|
93
|
+
* AzureOpenAI) reuse the same resource prototypes as the first-party clients,
|
|
94
|
+
* so the platform is read at call time from the resource's owning client:
|
|
95
|
+
* EXACT constructor names up the prototype chain first (exact, not substring
|
|
96
|
+
* — a class named `NotAzureOpenAI` must not match; subclasses match via their
|
|
97
|
+
* inherited base's name), then per-platform `baseURL` host predicates matching
|
|
98
|
+
* only official endpoint shapes.
|
|
99
|
+
*
|
|
100
|
+
* Takes the RESOURCE: the `_client` property access is a host-boundary read (a
|
|
101
|
+
* proxied resource can throw from its getter), so it happens inside this
|
|
102
|
+
* function's guard. Falls back whenever routing is not positively detectable;
|
|
103
|
+
* never throws.
|
|
104
|
+
*/
|
|
105
|
+
export function detectProviderFromResource(resource, classNameRules, hostRules, fallback) {
|
|
106
|
+
try {
|
|
107
|
+
const client = resource
|
|
108
|
+
?._client;
|
|
109
|
+
if (!client || typeof client !== "object")
|
|
110
|
+
return fallback;
|
|
111
|
+
let proto = Object.getPrototypeOf(client);
|
|
112
|
+
while (proto) {
|
|
113
|
+
const name = proto.constructor
|
|
114
|
+
?.name;
|
|
115
|
+
if (typeof name === "string") {
|
|
116
|
+
for (const [names, provider] of classNameRules) {
|
|
117
|
+
if (names.has(name))
|
|
118
|
+
return provider;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
proto = Object.getPrototypeOf(proto);
|
|
122
|
+
}
|
|
123
|
+
const raw = client.baseURL;
|
|
124
|
+
const host = raw ? new URL(String(raw)).hostname : "";
|
|
125
|
+
if (host) {
|
|
126
|
+
for (const [matchesHost, provider] of hostRules) {
|
|
127
|
+
if (matchesHost(host))
|
|
128
|
+
return provider;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* detection must never fault the host call */
|
|
134
|
+
}
|
|
135
|
+
return fallback;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=genai-content.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SpanKind, type Context, type Span, type Tracer } from "@opentelemetry/api";
|
|
2
|
+
import { type InternalLogger } from "./core.js";
|
|
3
|
+
/**
|
|
4
|
+
* Telemetry callbacks + span config a provider supplies to {@link instrumentCall}.
|
|
5
|
+
* The callbacks are the ONLY provider-specific code; they run inside `safe()`
|
|
6
|
+
* and may never reach the host call path.
|
|
7
|
+
*/
|
|
8
|
+
export interface CallInstrumentation {
|
|
9
|
+
tracer: Tracer;
|
|
10
|
+
spanName: string;
|
|
11
|
+
spanKind: SpanKind;
|
|
12
|
+
/** Resolves the parent context for the span (e.g. the enclosing agent span). */
|
|
13
|
+
parentContext: () => Context;
|
|
14
|
+
/** Prefix for `safe()` telemetry-failure sites, e.g. `"openai.create"`. */
|
|
15
|
+
sitePrefix: string;
|
|
16
|
+
internalLogger: InternalLogger;
|
|
17
|
+
/** Set request-side attributes + emit request log events (given the span). */
|
|
18
|
+
onStart: (span: Span) => void;
|
|
19
|
+
/** Set response-side attributes + emit the choice event (given the span). */
|
|
20
|
+
onSuccess: (span: Span, result: unknown) => void;
|
|
21
|
+
/** Record an error on the span. */
|
|
22
|
+
onError: (span: Span, err: unknown) => void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The single audited host boundary for provider `create()` instrumentation.
|
|
26
|
+
*
|
|
27
|
+
* STRUCTURAL GUARANTEE: `invoke()` (the host provider call) runs EXACTLY ONCE,
|
|
28
|
+
* and its return value / thrown error reaches the caller UNCHANGED, regardless
|
|
29
|
+
* of any telemetry failure. Nothing host-controllable — a customer's global
|
|
30
|
+
* OTel `ContextManager`, a hostile response/thenable, a broken tracer — is ever
|
|
31
|
+
* on the synchronous path that produces `invoke()`'s result:
|
|
32
|
+
*
|
|
33
|
+
* - span creation is `safe()`-guarded; on failure we run `invoke()`
|
|
34
|
+
* uninstrumented and return it;
|
|
35
|
+
* - all telemetry (request attrs/events, response attrs/events, error
|
|
36
|
+
* recording, span end) runs in `safe()` satellites that degrade to
|
|
37
|
+
* "no telemetry" on any throw;
|
|
38
|
+
* - `invoke()` itself is NOT wrapped in `otelContext.with(...)` or any other
|
|
39
|
+
* host-controllable operation — log-record→span linkage is carried by the
|
|
40
|
+
* explicit span passed to the emitters, not by ambient context.
|
|
41
|
+
*
|
|
42
|
+
* New providers supply only the telemetry callbacks, so they add no new way to
|
|
43
|
+
* break the host. Mirrors struct-sdk-python's generator sandwich in
|
|
44
|
+
* `_create_common` / `_wrap_create`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function instrumentCall(invoke: () => unknown, inst: CallInstrumentation): unknown;
|
|
47
|
+
//# sourceMappingURL=instrument.d.ts.map
|
|
@@ -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,13 @@ 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;
|
|
30
|
+
export declare function wrapCreate(original: CreateMethod): CreateMethod;
|
|
31
|
+
export declare function wrapStream(original: StreamMethod): StreamMethod;
|
|
25
32
|
/** @internal */
|
|
26
33
|
export type _PatchContextForTest = PatchContext;
|
|
27
34
|
/** @internal */
|