@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.
- package/README.md +84 -17
- package/dist/commonjs/context.d.ts +19 -0
- package/dist/commonjs/context.js +58 -0
- package/dist/commonjs/core.js +104 -8
- package/dist/commonjs/events.d.ts +17 -6
- package/dist/commonjs/events.js +82 -59
- package/dist/commonjs/genai-content.d.ts +52 -0
- package/dist/commonjs/genai-content.js +143 -0
- package/dist/commonjs/instrument.d.ts +47 -0
- package/dist/commonjs/instrument.js +158 -0
- package/dist/commonjs/integrations/anthropic-content.js +18 -6
- package/dist/commonjs/integrations/anthropic.d.ts +6 -1
- package/dist/commonjs/integrations/anthropic.js +113 -125
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +3 -0
- package/dist/commonjs/integrations/langchain-callback.js +84 -6
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/openai-content.d.ts +34 -0
- package/dist/commonjs/integrations/openai-content.js +375 -0
- package/dist/commonjs/integrations/openai.d.ts +39 -0
- package/dist/commonjs/integrations/openai.js +305 -0
- package/dist/commonjs/semconv.d.ts +1 -0
- package/dist/commonjs/semconv.js +1 -0
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/context.d.ts +19 -0
- package/dist/esm/context.js +56 -0
- package/dist/esm/core.js +104 -8
- package/dist/esm/events.d.ts +17 -6
- package/dist/esm/events.js +82 -61
- package/dist/esm/genai-content.d.ts +52 -0
- package/dist/esm/genai-content.js +137 -0
- package/dist/esm/instrument.d.ts +47 -0
- package/dist/esm/instrument.js +155 -0
- package/dist/esm/integrations/anthropic-content.js +19 -7
- package/dist/esm/integrations/anthropic.d.ts +6 -1
- package/dist/esm/integrations/anthropic.js +112 -126
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +3 -0
- package/dist/esm/integrations/langchain-callback.js +85 -7
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/openai-content.d.ts +34 -0
- package/dist/esm/integrations/openai-content.js +360 -0
- package/dist/esm/integrations/openai.d.ts +39 -0
- package/dist/esm/integrations/openai.js +296 -0
- package/dist/esm/semconv.d.ts +1 -0
- package/dist/esm/semconv.js +1 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +8 -2
package/dist/esm/context.js
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/esm/events.d.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
9
|
-
*
|
|
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
|
|
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
|
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.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
|
-
|
|
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
|