@struct-ai/sdk 0.3.17 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +84 -17
  2. package/dist/commonjs/context.d.ts +19 -0
  3. package/dist/commonjs/context.js +58 -0
  4. package/dist/commonjs/core.js +104 -8
  5. package/dist/commonjs/events.d.ts +17 -6
  6. package/dist/commonjs/events.js +82 -59
  7. package/dist/commonjs/genai-content.d.ts +52 -0
  8. package/dist/commonjs/genai-content.js +143 -0
  9. package/dist/commonjs/instrument.d.ts +47 -0
  10. package/dist/commonjs/instrument.js +158 -0
  11. package/dist/commonjs/integrations/anthropic-content.js +18 -6
  12. package/dist/commonjs/integrations/anthropic.d.ts +6 -1
  13. package/dist/commonjs/integrations/anthropic.js +113 -125
  14. package/dist/commonjs/integrations/index.js +8 -0
  15. package/dist/commonjs/integrations/langchain-callback.d.ts +3 -0
  16. package/dist/commonjs/integrations/langchain-callback.js +84 -6
  17. package/dist/commonjs/integrations/langchain-content.js +1 -1
  18. package/dist/commonjs/integrations/openai-content.d.ts +34 -0
  19. package/dist/commonjs/integrations/openai-content.js +375 -0
  20. package/dist/commonjs/integrations/openai.d.ts +39 -0
  21. package/dist/commonjs/integrations/openai.js +305 -0
  22. package/dist/commonjs/semconv.d.ts +1 -0
  23. package/dist/commonjs/semconv.js +1 -0
  24. package/dist/commonjs/truncation.d.ts +29 -0
  25. package/dist/commonjs/truncation.js +184 -10
  26. package/dist/commonjs/version.d.ts +1 -1
  27. package/dist/commonjs/version.js +1 -1
  28. package/dist/esm/context.d.ts +19 -0
  29. package/dist/esm/context.js +56 -0
  30. package/dist/esm/core.js +104 -8
  31. package/dist/esm/events.d.ts +17 -6
  32. package/dist/esm/events.js +82 -61
  33. package/dist/esm/genai-content.d.ts +52 -0
  34. package/dist/esm/genai-content.js +137 -0
  35. package/dist/esm/instrument.d.ts +47 -0
  36. package/dist/esm/instrument.js +155 -0
  37. package/dist/esm/integrations/anthropic-content.js +19 -7
  38. package/dist/esm/integrations/anthropic.d.ts +6 -1
  39. package/dist/esm/integrations/anthropic.js +112 -126
  40. package/dist/esm/integrations/index.js +8 -0
  41. package/dist/esm/integrations/langchain-callback.d.ts +3 -0
  42. package/dist/esm/integrations/langchain-callback.js +85 -7
  43. package/dist/esm/integrations/langchain-content.js +1 -1
  44. package/dist/esm/integrations/openai-content.d.ts +34 -0
  45. package/dist/esm/integrations/openai-content.js +360 -0
  46. package/dist/esm/integrations/openai.d.ts +39 -0
  47. package/dist/esm/integrations/openai.js +296 -0
  48. package/dist/esm/semconv.d.ts +1 -0
  49. package/dist/esm/semconv.js +1 -0
  50. package/dist/esm/truncation.d.ts +29 -0
  51. package/dist/esm/truncation.js +182 -10
  52. package/dist/esm/version.d.ts +1 -1
  53. package/dist/esm/version.js +1 -1
  54. package/package.json +8 -2
@@ -94,4 +94,60 @@ export function runWithStore(store, fn) {
94
94
  export function runInFreshContext(fn) {
95
95
  return als.run({}, fn);
96
96
  }
97
+ /**
98
+ * Write-once `gen_ai.provider.name` on an invoke_agent span.
99
+ *
100
+ * The GenAI spec puts `gen_ai.provider.name` on invoke_agent spans, but the
101
+ * layer that CREATES those spans (`struct.agent()`, the LangChain handler) is
102
+ * provider-agnostic and can't know the value yet — only the child inference
103
+ * call can. The first chat call within the agent scope stamps it (best
104
+ * knowledge; first one wins); an agent that never reaches a model omits the
105
+ * attribute rather than carrying a framework name. Parity: python
106
+ * `_genai_content.stamp_provider_once`.
107
+ */
108
+ const PROVIDER_STAMPED = Symbol("struct.providerStamped");
109
+ /**
110
+ * Write-once `gen_ai.provider.name` on an invoke_agent span.
111
+ *
112
+ * CONTRACT: `agentSpan` is always an SDK-OWNED span — created by our own
113
+ * tracer in `struct.agent()` or the LangChain handler and delivered via the
114
+ * ALS store / run map, which nothing else writes. Never a host object. So
115
+ * the industry-standard owned-object pattern applies (state lives ON the
116
+ * object — Sentry/dd-trace private span fields, OTel JS symbol markers): a
117
+ * private Symbol sentinel set after a successful write. No registries or
118
+ * lifecycle bookkeeping — those are for FOREIGN objects.
119
+ *
120
+ * Semantics: "a real child provider" — racing children with different
121
+ * providers may pick either; the sentinel is set only after a successful
122
+ * write so a transient failure can be retried. Parity: python
123
+ * `stamp_provider_once`.
124
+ */
125
+ export function stampProviderOnce(agentSpan, provider) {
126
+ try {
127
+ if (!agentSpan || !provider)
128
+ return;
129
+ const marked = agentSpan;
130
+ if (marked[PROVIDER_STAMPED])
131
+ return;
132
+ agentSpan.setAttribute("gen_ai.provider.name", provider);
133
+ try {
134
+ marked[PROVIDER_STAMPED] = true;
135
+ }
136
+ catch {
137
+ /* frozen span — worst case a later child re-stamps a real provider */
138
+ }
139
+ }
140
+ catch {
141
+ /* never fail the application for telemetry */
142
+ }
143
+ }
144
+ /** Stamp the ambient agent span with the child call's provider (write-once). */
145
+ export function propagateProviderToParent(provider) {
146
+ try {
147
+ stampProviderOnce(getAgentSpan(), provider);
148
+ }
149
+ catch {
150
+ /* never fail the application for telemetry */
151
+ }
152
+ }
97
153
  //# sourceMappingURL=context.js.map
package/dist/esm/core.js CHANGED
@@ -84,12 +84,22 @@ export function safe(fn, site, logger) {
84
84
  fn();
85
85
  }
86
86
  catch (err) {
87
- if (firstFailureLogged.has(site)) {
88
- logger.debug(`Struct SDK suppressed exception at ${site}`, err);
87
+ // The diagnostic log MUST NOT itself throw into the host: the default
88
+ // logger reaches `console.warn`, which a host may have replaced with a
89
+ // throwing implementation, and callers pass custom loggers. A throw here
90
+ // would escape `safe()` and defeat its whole purpose (e.g. block the host
91
+ // call when span creation fails). Guard the logging too.
92
+ try {
93
+ if (firstFailureLogged.has(site)) {
94
+ logger.debug(`Struct SDK suppressed exception at ${site}`, err);
95
+ }
96
+ else {
97
+ firstFailureLogged.add(site);
98
+ logger.warn(`Struct SDK suppressed exception at ${site}`, err);
99
+ }
89
100
  }
90
- else {
91
- firstFailureLogged.add(site);
92
- logger.warn(`Struct SDK suppressed exception at ${site}`, err);
101
+ catch {
102
+ /* diagnostic logging failed — never propagate into the host path */
93
103
  }
94
104
  }
95
105
  }
@@ -380,7 +390,10 @@ export class StructSDK {
380
390
  const startedSpan = span;
381
391
  safe(() => {
382
392
  startedSpan.setAttribute(GEN_AI.OPERATION_NAME, "invoke_agent");
383
- startedSpan.setAttribute(GEN_AI.PROVIDER_NAME, "struct");
393
+ // gen_ai.provider.name is NOT set here: this layer is
394
+ // provider-agnostic, and a framework name ("struct") isn't a
395
+ // provider. The first child inference call stamps the real one via
396
+ // propagateProviderToParent (write-once, best knowledge).
384
397
  startedSpan.setAttribute(GEN_AI.AGENT_NAME, agentName);
385
398
  // gen_ai.agent.id is the stable identifier of the agent
386
399
  // DEFINITION. Only set it when the caller provides one — we do
@@ -475,7 +488,8 @@ export class StructSDK {
475
488
  const startedSpan = span;
476
489
  safe(() => {
477
490
  startedSpan.setAttribute(GEN_AI.OPERATION_NAME, "execute_tool");
478
- startedSpan.setAttribute(GEN_AI.PROVIDER_NAME, "struct");
491
+ // No gen_ai.provider.name: the spec's execute_tool span does not
492
+ // define that attribute.
479
493
  startedSpan.setAttribute(GEN_AI.TOOL_NAME, toolName);
480
494
  if (toolCallId) {
481
495
  startedSpan.setAttribute(GEN_AI.TOOL_CALL_ID, toolCallId);
@@ -491,7 +505,28 @@ export class StructSDK {
491
505
  if (this.captureContent && result !== undefined && result !== null) {
492
506
  safe(() => startedSpan.setAttribute(GEN_AI.TOOL_CALL_RESULT, safeJsonStringify(result).slice(0, 8192)), "tool.set_result_attr", this._internalLogger);
493
507
  }
494
- safe(() => startedSpan.setStatus({ code: SpanStatusCode.OK }), "tool.set_ok_status", this._internalLogger);
508
+ if (toolResultSignalsError(result)) {
509
+ safe(() => {
510
+ startedSpan.setAttribute(ERROR_TYPE, "tool_error");
511
+ // The status message is content placed on a SPAN — it follows
512
+ // the span-content routing gate (emitSpanContent), not merely
513
+ // "any capture on": under EventOnly (the default) content
514
+ // routes to log events, so span text gets the fixed literal.
515
+ // Deliberate boundary: gen_ai.tool.call.result above stays on
516
+ // captureContent — tool spans have no log-event equivalent for
517
+ // results, so the attribute is the sanctioned tool-content
518
+ // channel in every non-None mode. Mirrors python _note_result.
519
+ startedSpan.setStatus({
520
+ code: SpanStatusCode.ERROR,
521
+ message: this.emitSpanContent
522
+ ? toolErrorStatusMessage(result)
523
+ : "tool returned is_error=true",
524
+ });
525
+ }, "tool.set_tool_error_status", this._internalLogger);
526
+ }
527
+ else {
528
+ safe(() => startedSpan.setStatus({ code: SpanStatusCode.OK }), "tool.set_ok_status", this._internalLogger);
529
+ }
495
530
  return result;
496
531
  }
497
532
  catch (err) {
@@ -512,4 +547,65 @@ function recordError(span, err) {
512
547
  span.recordException(err);
513
548
  }
514
549
  }
550
+ /**
551
+ * Read one property from untrusted host data, isolating the read in its
552
+ * own try — a hostile getter/Proxy trap on ONE key must never throw into
553
+ * tool()'s try block (rejecting the host's successful call) nor mask a
554
+ * readable value on a SIBLING key (callers probe each supported alias
555
+ * independently). Mirrors python `_safe_probe` — keep in lockstep.
556
+ */
557
+ function safeProbe(obj, key) {
558
+ if (typeof obj !== "object" || obj === null)
559
+ return undefined;
560
+ try {
561
+ return obj[key];
562
+ }
563
+ catch {
564
+ return undefined;
565
+ }
566
+ }
567
+ /**
568
+ * Whether a tool's RETURN VALUE signals in-band failure (MCP
569
+ * CallToolResult.isError / Anthropic tool_result.is_error). Strictly
570
+ * boolean `true` on the top-level object — truthy strings/numbers and
571
+ * nested flags do not trigger (host data is untrusted; be conservative).
572
+ * Each alias is probed independently so a hostile getter on one cannot
573
+ * mask the other. Mirrors python `_tool_result_signals_error`.
574
+ */
575
+ function toolResultSignalsError(result) {
576
+ return (safeProbe(result, "isError") === true ||
577
+ safeProbe(result, "is_error") === true);
578
+ }
579
+ /** Short status message from an error result's content, else a fixed one.
580
+ *
581
+ * TOTAL FUNCTION: never throws and does bounded work. It runs inside the
582
+ * safe() closure that also sets span status — a hostile Proxy whose
583
+ * `length`/index traps throw must not abort that closure (which would
584
+ * leave the span UNSET instead of ERROR). Two layers: per-key safeProbe
585
+ * isolation (one hostile item cannot mask a later readable one) INSIDE a
586
+ * whole-body catch (collection machinery itself is untrusted), index
587
+ * loop instead of for..of (iterator protocol is trappable), traversal
588
+ * capped at 20 items. Mirrors python `_tool_error_status_message`. */
589
+ function toolErrorStatusMessage(result) {
590
+ try {
591
+ const content = safeProbe(result, "content");
592
+ if (typeof content === "string" && content)
593
+ return content.slice(0, 256);
594
+ if (Array.isArray(content)) {
595
+ const len = Math.min(content.length, 20);
596
+ for (let i = 0; i < len; i++) {
597
+ const item = safeProbe(content, String(i));
598
+ const text = safeProbe(item, "text");
599
+ // Truthiness (not just typeof) matches the python twin: empty-string
600
+ // text items are skipped, falling through to the fixed message.
601
+ if (typeof text === "string" && text)
602
+ return text.slice(0, 256);
603
+ }
604
+ }
605
+ }
606
+ catch {
607
+ // Hostile collection — fall through to the fixed message.
608
+ }
609
+ return "tool returned is_error=true";
610
+ }
515
611
  //# sourceMappingURL=core.js.map
@@ -1,12 +1,23 @@
1
- import { type Logger } from "@opentelemetry/api-logs";
1
+ import type { Logger } from "@opentelemetry/api-logs";
2
+ import type { Span } from "@opentelemetry/api";
2
3
  /**
3
4
  * Emit per-message log events for an Anthropic messages.create() call.
4
- * Port of _emit_message_events from anthropic.py.
5
+ * Delegates the LogRecord wiring to the shared genai-content emitters; this
6
+ * function is only the Anthropic message → parts mapping + ordering.
5
7
  */
6
- export declare function emitAnthropicMessageEvents(logger: Logger, messages: unknown, system: unknown): void;
8
+ export declare function emitAnthropicMessageEvents(logger: Logger, messages: unknown, system: unknown, span?: Span, provider?: string): void;
9
+ /** Emit a gen_ai.choice LogRecord for an Anthropic response. */
10
+ export declare function emitAnthropicChoiceEvent(logger: Logger, contentBlocks: unknown, stopReason: string | null | undefined, span?: Span, provider?: string): void;
7
11
  /**
8
- * Emit a gen_ai.choice LogRecord for an Anthropic response.
9
- * Port of _emit_choice_event from anthropic.py.
12
+ * Emit per-message log events for an OpenAI responses.create() call.
13
+ * `instructions` (the Responses system prompt) is emitted FIRST at index 0.
14
+ * Delegates LogRecord wiring to the shared emitters; only the Responses
15
+ * item → event mapping + ordering lives here.
10
16
  */
11
- export declare function emitAnthropicChoiceEvent(logger: Logger, contentBlocks: unknown, stopReason: string | null | undefined): void;
17
+ export declare function emitOpenAIInputMessageEvents(logger: Logger, input: unknown, instructions: unknown, span?: Span, provider?: string): void;
18
+ /**
19
+ * Emit the gen_ai.choice LogRecord from an OpenAI response.output.
20
+ * `finishReason` is mapped inside this emitter using mapChoiceFinishReason.
21
+ */
22
+ export declare function emitOpenAIChoiceEvent(logger: Logger, output: unknown, finishReason: string | undefined, span?: Span, provider?: string): void;
12
23
  //# sourceMappingURL=events.d.ts.map
@@ -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.PROVIDER_NAME]: "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.PROVIDER_NAME]: "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.PROVIDER_NAME]: "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