@struct-ai/sdk 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +101 -16
  2. package/dist/commonjs/context.d.ts +45 -0
  3. package/dist/commonjs/context.js +78 -1
  4. package/dist/commonjs/core.js +184 -29
  5. package/dist/commonjs/events.d.ts +17 -6
  6. package/dist/commonjs/events.js +82 -59
  7. package/dist/commonjs/genai-content.d.ts +52 -0
  8. package/dist/commonjs/genai-content.js +143 -0
  9. package/dist/commonjs/instrument.d.ts +47 -0
  10. package/dist/commonjs/instrument.js +158 -0
  11. package/dist/commonjs/integrations/anthropic-content.js +18 -6
  12. package/dist/commonjs/integrations/anthropic.d.ts +8 -1
  13. package/dist/commonjs/integrations/anthropic.js +515 -104
  14. package/dist/commonjs/integrations/index.js +8 -0
  15. package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
  16. package/dist/commonjs/integrations/langchain-callback.js +754 -87
  17. package/dist/commonjs/integrations/langchain-content.js +1 -1
  18. package/dist/commonjs/integrations/langchain.d.ts +3 -0
  19. package/dist/commonjs/integrations/langchain.js +353 -7
  20. package/dist/commonjs/integrations/openai-content.d.ts +34 -0
  21. package/dist/commonjs/integrations/openai-content.js +375 -0
  22. package/dist/commonjs/integrations/openai.d.ts +39 -0
  23. package/dist/commonjs/integrations/openai.js +305 -0
  24. package/dist/commonjs/semconv.d.ts +12 -0
  25. package/dist/commonjs/semconv.js +13 -1
  26. package/dist/commonjs/truncation.d.ts +29 -0
  27. package/dist/commonjs/truncation.js +184 -10
  28. package/dist/commonjs/version.d.ts +2 -0
  29. package/dist/commonjs/version.js +6 -0
  30. package/dist/esm/context.d.ts +45 -0
  31. package/dist/esm/context.js +74 -1
  32. package/dist/esm/core.js +185 -30
  33. package/dist/esm/events.d.ts +17 -6
  34. package/dist/esm/events.js +82 -61
  35. package/dist/esm/genai-content.d.ts +52 -0
  36. package/dist/esm/genai-content.js +137 -0
  37. package/dist/esm/instrument.d.ts +47 -0
  38. package/dist/esm/instrument.js +155 -0
  39. package/dist/esm/integrations/anthropic-content.js +19 -7
  40. package/dist/esm/integrations/anthropic.d.ts +8 -1
  41. package/dist/esm/integrations/anthropic.js +514 -107
  42. package/dist/esm/integrations/index.js +8 -0
  43. package/dist/esm/integrations/langchain-callback.d.ts +182 -27
  44. package/dist/esm/integrations/langchain-callback.js +756 -89
  45. package/dist/esm/integrations/langchain-content.js +1 -1
  46. package/dist/esm/integrations/langchain.d.ts +3 -0
  47. package/dist/esm/integrations/langchain.js +352 -7
  48. package/dist/esm/integrations/openai-content.d.ts +34 -0
  49. package/dist/esm/integrations/openai-content.js +360 -0
  50. package/dist/esm/integrations/openai.d.ts +39 -0
  51. package/dist/esm/integrations/openai.js +296 -0
  52. package/dist/esm/semconv.d.ts +12 -0
  53. package/dist/esm/semconv.js +12 -0
  54. package/dist/esm/truncation.d.ts +29 -0
  55. package/dist/esm/truncation.js +182 -10
  56. package/dist/esm/version.d.ts +2 -0
  57. package/dist/esm/version.js +3 -0
  58. package/package.json +11 -3
@@ -1,37 +1,13 @@
1
- import { context as otelContext } from "@opentelemetry/api";
2
- import { SeverityNumber, } from "@opentelemetry/api-logs";
3
- import { getSessionId } from "./context.js";
4
- import { ANTHROPIC_FINISH_REASON_MAP, EVENT_NAME, EVENT_NAMES, GEN_AI, ROLE_TO_EVENT_NAME, } from "./semconv.js";
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
- * Port of _emit_message_events from anthropic.py.
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
- emitLogRecord({
20
+ emitMessageEvent({
45
21
  logger,
22
+ role: "system",
23
+ parts,
46
24
  eventName: EVENT_NAMES.SYSTEM_MESSAGE,
47
- payload: safeJsonStringify({
48
- role: "system",
49
- parts: truncateParts(parts),
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
- emitLogRecord({
38
+ emitMessageEvent({
66
39
  logger,
40
+ role,
41
+ parts,
67
42
  eventName,
68
- payload: safeJsonStringify({ role, parts: truncateParts(parts) }),
69
- extraAttrs: {
70
- [GEN_AI.SYSTEM]: "anthropic",
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
- * Emit a gen_ai.choice LogRecord for an Anthropic response.
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
- const payload = safeJsonStringify({
86
- index: 0,
87
- finish_reason: mappedReason,
88
- message: { role: "assistant", parts: truncateParts(parts) },
55
+ emitChoiceEvent({
56
+ logger,
57
+ parts,
58
+ finishReason: mappedReason,
59
+ provider,
60
+ span,
89
61
  });
90
- emitLogRecord({
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
- eventName: EVENT_NAMES.CHOICE,
93
- payload,
94
- extraAttrs: {
95
- [GEN_AI.SYSTEM]: "anthropic",
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 { 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,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 */