@struct-ai/sdk 0.2.1 → 0.3.17
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 +48 -8
- package/dist/commonjs/context.d.ts +26 -0
- package/dist/commonjs/context.js +20 -1
- package/dist/commonjs/core.d.ts +2 -0
- package/dist/commonjs/core.js +133 -28
- package/dist/commonjs/events.js +3 -3
- package/dist/commonjs/integrations/anthropic.d.ts +2 -0
- package/dist/commonjs/integrations/anthropic.js +470 -47
- package/dist/commonjs/integrations/langchain-callback.d.ts +179 -27
- package/dist/commonjs/integrations/langchain-callback.js +670 -81
- package/dist/commonjs/integrations/langchain.d.ts +3 -0
- package/dist/commonjs/integrations/langchain.js +353 -7
- package/dist/commonjs/semconv.d.ts +11 -0
- package/dist/commonjs/semconv.js +12 -1
- package/dist/commonjs/version.d.ts +2 -0
- package/dist/commonjs/version.js +6 -0
- package/dist/esm/context.d.ts +26 -0
- package/dist/esm/context.js +18 -1
- package/dist/esm/core.d.ts +2 -0
- package/dist/esm/core.js +135 -30
- package/dist/esm/events.js +3 -3
- package/dist/esm/integrations/anthropic.d.ts +2 -0
- package/dist/esm/integrations/anthropic.js +471 -50
- package/dist/esm/integrations/langchain-callback.d.ts +179 -27
- package/dist/esm/integrations/langchain-callback.js +672 -83
- package/dist/esm/integrations/langchain.d.ts +3 -0
- package/dist/esm/integrations/langchain.js +352 -7
- package/dist/esm/semconv.d.ts +11 -0
- package/dist/esm/semconv.js +11 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +3 -0
- package/package.json +13 -11
package/README.md
CHANGED
|
@@ -55,9 +55,9 @@ await struct.agent({ name: "checkout" }, async () => {
|
|
|
55
55
|
|
|
56
56
|
| Library | Hook | Span type | Notes |
|
|
57
57
|
|---|---|---|---|
|
|
58
|
-
| `@anthropic-ai/sdk` | `Messages.prototype.create`, `.stream` | `chat {model}` | Cache-token accounting, streaming with tool-use reconstruction |
|
|
58
|
+
| `@anthropic-ai/sdk` | `Messages.prototype.create`, `.stream` | `chat {model}` | Cache-token accounting, streaming with tool-use reconstruction. Defers to a LangChain `BaseChatModel` call already in progress (e.g. `ChatAnthropic`) so the two integrations don't double-emit — see ownership order below |
|
|
59
59
|
| `@anthropic-ai/bedrock-sdk`, `@anthropic-ai/vertex-sdk` | `Messages.prototype.*` | `chat {model}` | Best-effort, if installed |
|
|
60
|
-
| `@langchain/core` `BaseChatModel` | `.invoke`, `.stream` | `chat {model}` |
|
|
60
|
+
| `@langchain/core` `BaseChatModel` | `.invoke`, `.stream` | `chat {model}` | Always owns the `chat` span for `ChatAnthropic`/etc. calls, even when a provider-direct instrumentor (e.g. the Anthropic patch) is also active — the framework layer outranks the provider layer (single span; see [AGENTS.md](./AGENTS.md) §5) |
|
|
61
61
|
| `@langchain/core` `StructuredTool` | `.invoke` | `execute_tool {name}` | Extracts `tool_call_id` from LangChain ToolCall input or pending queue |
|
|
62
62
|
| `@langchain/core` `BaseRetriever` | `.invoke` | `retrieval {name}` | |
|
|
63
63
|
| `@langchain/langgraph` `Pregel` | `.invoke`, `.stream` | `invoke_agent {name}` | Covers `createReactAgent` and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent({ sessionId: convId }, ...)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — the struct-native replacement for LangSmith's `tracing_context(parent=run_tree)`. |
|
|
@@ -189,8 +189,8 @@ await struct.agent({ name: "my-agent" }, async () => {
|
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
When you do use `ChatAnthropic` *and* have `@anthropic-ai/sdk` installed,
|
|
192
|
-
the chat span comes from the
|
|
193
|
-
|
|
192
|
+
the chat span comes from the LangChain layer (single span); the Anthropic
|
|
193
|
+
patch detects the framework already owns the call and defers.
|
|
194
194
|
|
|
195
195
|
## Content capture
|
|
196
196
|
|
|
@@ -264,6 +264,36 @@ Note: `gen_ai.usage.input_tokens` for Anthropic is the TRUE total — we add bac
|
|
|
264
264
|
`cache_read_input_tokens + cache_creation_input_tokens` (which Anthropic's raw
|
|
265
265
|
response excludes). Matches the Python SDK.
|
|
266
266
|
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
- `pnpm test` — unit + e2e tests (mocked, no network, no API keys). Excludes
|
|
270
|
+
`test/live/**`.
|
|
271
|
+
- `pnpm typecheck` — `tsc --noEmit`.
|
|
272
|
+
- `pnpm build` — `tshy`, producing the dual ESM/CJS `dist/`.
|
|
273
|
+
- `pnpm test:live` — the live real-model suite: real `@langchain/anthropic` +
|
|
274
|
+
real Anthropic API + real `@langchain/langgraph`, verifying emitted spans
|
|
275
|
+
in-memory (no ingester needed). Skips cleanly (exit 0, all tests skipped)
|
|
276
|
+
when no API key is present — safe to leave in normal CI.
|
|
277
|
+
|
|
278
|
+
To actually run it, point `STRUCT_LIVE_ENV_FILE` at a file containing
|
|
279
|
+
`ANTHROPIC_API_KEY=...` (dotenv-style `KEY=value` lines; quotes optional).
|
|
280
|
+
The path is never committed to the repo — it's supplied per-invocation:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
STRUCT_LIVE_ENV_FILE=/path/to/your/.env pnpm test:live
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Uses the cheapest current Anthropic model alias with small `max_tokens`
|
|
287
|
+
budgets — a handful of real API calls per run, not dozens.
|
|
288
|
+
- `pnpm test:parity` — the cross-language conformance harness: drives the
|
|
289
|
+
same canonical agent topology through both this SDK and `struct-sdk-python`
|
|
290
|
+
and diffs their normalized span shapes, failing (non-zero exit) on any
|
|
291
|
+
divergence. No network calls or API keys required.
|
|
292
|
+
|
|
293
|
+
See [AGENTS.md](./AGENTS.md) for the full set of governance rules (fault
|
|
294
|
+
isolation, OTel citizenship, semconv conformance, the release-version-bump
|
|
295
|
+
gate) that apply to any change in this package.
|
|
296
|
+
|
|
267
297
|
## Troubleshooting
|
|
268
298
|
|
|
269
299
|
- **Spans missing after instrumenting:** Import `@struct-ai/sdk` (or `struct.init()`)
|
|
@@ -273,10 +303,20 @@ response excludes). Matches the Python SDK.
|
|
|
273
303
|
- **No logs appearing:** `LogRecord`s only emit when `sdk.emitEvents` is true
|
|
274
304
|
(`EventOnly` or `SpanAndEvent` capture mode, which is the default). If you set
|
|
275
305
|
`captureContent: false` you disable them.
|
|
276
|
-
- **Duplicate chat spans:**
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
306
|
+
- **Duplicate chat spans:** Ownership of the `chat` span is fixed (manual >
|
|
307
|
+
framework > provider — see [AGENTS.md](./AGENTS.md) §5): when you call
|
|
308
|
+
`ChatAnthropic.invoke()`/`.stream()`, the LangChain integration always owns
|
|
309
|
+
the `chat` span (`handleChatModelStart` in `langchain-callback.ts`). It runs
|
|
310
|
+
the underlying provider call inside an internal scope
|
|
311
|
+
(`suppressGenAi: true`) that the direct `@anthropic-ai/sdk` patch checks via
|
|
312
|
+
`isGenAiSuppressed()` — when set, the provider patch defers and emits
|
|
313
|
+
nothing, so only the LangChain-owned span is produced. If you see doubles,
|
|
314
|
+
the two integrations are likely observing different `@anthropic-ai/sdk`
|
|
315
|
+
module instances (common with pnpm hoisting a nested copy under
|
|
316
|
+
`@langchain/anthropic`), so the provider patch never sees the suppression
|
|
317
|
+
scope set by the framework layer; confirm both integrations are
|
|
318
|
+
auto-instrumenting the SAME module instance (check `struct.initialized` and
|
|
319
|
+
your lockfile's dedupe of `@anthropic-ai/sdk`).
|
|
280
320
|
- **Subagent in a different trace / missing from parent's "Subagents" list:**
|
|
281
321
|
If you invoke a nested agent (`subagent.invoke(...)`) from inside a tool body,
|
|
282
322
|
define the outer tool with `tool(func, { name, description, schema })` from
|
|
@@ -4,12 +4,38 @@ export interface StructContext {
|
|
|
4
4
|
conversationId?: string;
|
|
5
5
|
agentSpan?: Span;
|
|
6
6
|
pendingToolCalls?: Record<string, string[]>;
|
|
7
|
+
/**
|
|
8
|
+
* When true, a framework-layer integration (e.g. LangChain's
|
|
9
|
+
* BaseChatModel.generate/.stream) already owns the chat span for the call
|
|
10
|
+
* in progress. Provider-SDK patches (anthropic.ts) check this and skip
|
|
11
|
+
* emitting their own chat span to avoid a duplicate.
|
|
12
|
+
*/
|
|
13
|
+
suppressGenAi?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Set by `struct.agent()` while its body is running. Signals ownership:
|
|
16
|
+
* when the LangChain callback handler sees a TOP-LEVEL chain start (no
|
|
17
|
+
* parentRunId) while this is set, the manual `struct.agent()` call already
|
|
18
|
+
* owns the `invoke_agent` span for this run — the handler must not emit a
|
|
19
|
+
* duplicate ("twin") span, only register the run so descendants parent
|
|
20
|
+
* under the manual span. Parity: python `_manual_agent_active` contextvar
|
|
21
|
+
* (core.py:64-68).
|
|
22
|
+
*/
|
|
23
|
+
manualAgentSpan?: Span;
|
|
7
24
|
}
|
|
8
25
|
export declare function getStore(): StructContext | undefined;
|
|
9
26
|
export declare function getSessionId(): string | undefined;
|
|
10
27
|
export declare function getConversationId(): string | undefined;
|
|
11
28
|
export declare function getAgentSpan(): Span | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The manually-created `struct.agent()` span for the currently-running
|
|
31
|
+
* scope, if any. Read by the LangChain callback handler to detect
|
|
32
|
+
* "manual struct.agent() wraps a top-level LangChain chain" and suppress
|
|
33
|
+
* the twin `invoke_agent` span it would otherwise emit.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getManualAgentSpan(): Span | undefined;
|
|
12
36
|
export declare function getPendingToolCalls(): Record<string, string[]> | undefined;
|
|
37
|
+
/** True when a framework layer (e.g. LangChain) already owns the chat span. */
|
|
38
|
+
export declare function isGenAiSuppressed(): boolean;
|
|
13
39
|
export declare function runWithContext<T>(patch: Partial<StructContext>, fn: () => T): T;
|
|
14
40
|
export declare function ensurePendingToolCallsSlot(): Record<string, string[]>;
|
|
15
41
|
/**
|
package/dist/commonjs/context.js
CHANGED
|
@@ -4,7 +4,9 @@ exports.getStore = getStore;
|
|
|
4
4
|
exports.getSessionId = getSessionId;
|
|
5
5
|
exports.getConversationId = getConversationId;
|
|
6
6
|
exports.getAgentSpan = getAgentSpan;
|
|
7
|
+
exports.getManualAgentSpan = getManualAgentSpan;
|
|
7
8
|
exports.getPendingToolCalls = getPendingToolCalls;
|
|
9
|
+
exports.isGenAiSuppressed = isGenAiSuppressed;
|
|
8
10
|
exports.runWithContext = runWithContext;
|
|
9
11
|
exports.ensurePendingToolCallsSlot = ensurePendingToolCallsSlot;
|
|
10
12
|
exports.popPendingToolCallId = popPendingToolCallId;
|
|
@@ -26,9 +28,22 @@ function getConversationId() {
|
|
|
26
28
|
function getAgentSpan() {
|
|
27
29
|
return als.getStore()?.agentSpan;
|
|
28
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The manually-created `struct.agent()` span for the currently-running
|
|
33
|
+
* scope, if any. Read by the LangChain callback handler to detect
|
|
34
|
+
* "manual struct.agent() wraps a top-level LangChain chain" and suppress
|
|
35
|
+
* the twin `invoke_agent` span it would otherwise emit.
|
|
36
|
+
*/
|
|
37
|
+
function getManualAgentSpan() {
|
|
38
|
+
return als.getStore()?.manualAgentSpan;
|
|
39
|
+
}
|
|
29
40
|
function getPendingToolCalls() {
|
|
30
41
|
return als.getStore()?.pendingToolCalls;
|
|
31
42
|
}
|
|
43
|
+
/** True when a framework layer (e.g. LangChain) already owns the chat span. */
|
|
44
|
+
function isGenAiSuppressed() {
|
|
45
|
+
return als.getStore()?.suppressGenAi === true;
|
|
46
|
+
}
|
|
32
47
|
function runWithContext(patch, fn) {
|
|
33
48
|
const current = als.getStore() ?? {};
|
|
34
49
|
const next = { ...current, ...patch };
|
|
@@ -80,7 +95,11 @@ function pushPendingToolCalls(pairs) {
|
|
|
80
95
|
/** Snapshot the store for wrapping async iterators. */
|
|
81
96
|
function snapshotStore() {
|
|
82
97
|
const store = als.getStore();
|
|
83
|
-
|
|
98
|
+
if (!store)
|
|
99
|
+
return undefined;
|
|
100
|
+
if (!store.pendingToolCalls)
|
|
101
|
+
store.pendingToolCalls = {}; // materialize BEFORE copying so the queue object is shared by reference
|
|
102
|
+
return { ...store };
|
|
84
103
|
}
|
|
85
104
|
function runWithStore(store, fn) {
|
|
86
105
|
if (!store)
|
package/dist/commonjs/core.d.ts
CHANGED
package/dist/commonjs/core.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.StructSDK = exports.firstFailureLogged = exports.DEFAULT_ENDPOINT = void 0;
|
|
4
4
|
exports.safe = safe;
|
|
5
|
-
const node_crypto_1 = require("node:crypto");
|
|
6
5
|
const api_1 = require("@opentelemetry/api");
|
|
7
6
|
const exporter_logs_otlp_proto_1 = require("@opentelemetry/exporter-logs-otlp-proto");
|
|
8
7
|
const exporter_trace_otlp_proto_1 = require("@opentelemetry/exporter-trace-otlp-proto");
|
|
@@ -14,6 +13,7 @@ const context_js_1 = require("./context.js");
|
|
|
14
13
|
const index_js_1 = require("./integrations/index.js");
|
|
15
14
|
const semconv_js_1 = require("./semconv.js");
|
|
16
15
|
const truncation_js_1 = require("./truncation.js");
|
|
16
|
+
const version_js_1 = require("./version.js");
|
|
17
17
|
exports.DEFAULT_ENDPOINT = "https://ingest.struct.ai";
|
|
18
18
|
const LOG_ENABLED = process.env.STRUCT_SDK_LOG === "1";
|
|
19
19
|
const defaultInternalLogger = {
|
|
@@ -37,6 +37,43 @@ const defaultInternalLogger = {
|
|
|
37
37
|
* so consumers cannot reach it via the package's public API surface.
|
|
38
38
|
*/
|
|
39
39
|
exports.firstFailureLogged = new Set();
|
|
40
|
+
/**
|
|
41
|
+
* OTel SDK 2.x providers accept processors only at construction time —
|
|
42
|
+
* `addSpanProcessor` / `addLogRecordProcessor` no longer exist on the
|
|
43
|
+
* providers. These fan-outs are registered at construction and delegate to a
|
|
44
|
+
* mutable list, preserving the SDK's test-only `addSpanProcessor` /
|
|
45
|
+
* `addLogRecordProcessor` hooks.
|
|
46
|
+
*/
|
|
47
|
+
class FanoutSpanProcessor {
|
|
48
|
+
delegates = [];
|
|
49
|
+
onStart(...args) {
|
|
50
|
+
for (const p of this.delegates)
|
|
51
|
+
p.onStart(...args);
|
|
52
|
+
}
|
|
53
|
+
onEnd(...args) {
|
|
54
|
+
for (const p of this.delegates)
|
|
55
|
+
p.onEnd(...args);
|
|
56
|
+
}
|
|
57
|
+
forceFlush() {
|
|
58
|
+
return Promise.all(this.delegates.map((p) => p.forceFlush())).then(() => undefined);
|
|
59
|
+
}
|
|
60
|
+
shutdown() {
|
|
61
|
+
return Promise.all(this.delegates.map((p) => p.shutdown())).then(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
class FanoutLogRecordProcessor {
|
|
65
|
+
delegates = [];
|
|
66
|
+
onEmit(...args) {
|
|
67
|
+
for (const p of this.delegates)
|
|
68
|
+
p.onEmit(...args);
|
|
69
|
+
}
|
|
70
|
+
forceFlush() {
|
|
71
|
+
return Promise.all(this.delegates.map((p) => p.forceFlush())).then(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
shutdown() {
|
|
74
|
+
return Promise.all(this.delegates.map((p) => p.shutdown())).then(() => undefined);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
40
77
|
/**
|
|
41
78
|
* Run `fn`; swallow any exception. The first failure per `site` logs at WARN
|
|
42
79
|
* (with the error attached); subsequent failures at the same site log at
|
|
@@ -64,6 +101,8 @@ class StructSDK {
|
|
|
64
101
|
_initialized = false;
|
|
65
102
|
_tracerProvider;
|
|
66
103
|
_loggerProvider;
|
|
104
|
+
_spanFanout;
|
|
105
|
+
_logFanout;
|
|
67
106
|
_ingestKey = "";
|
|
68
107
|
_endpoint = exports.DEFAULT_ENDPOINT;
|
|
69
108
|
_contentCapture = content_capture_js_1.ContentCaptureMode.EventOnly;
|
|
@@ -137,26 +176,31 @@ class StructSDK {
|
|
|
137
176
|
maxExportBatchSize: 100,
|
|
138
177
|
scheduledDelayMillis: 1000,
|
|
139
178
|
});
|
|
140
|
-
const resource =
|
|
179
|
+
const resource = (0, resources_1.resourceFromAttributes)({
|
|
141
180
|
"service.name": serviceName,
|
|
142
181
|
"service.version": serviceVersion,
|
|
143
182
|
"deployment.environment": environment,
|
|
144
183
|
});
|
|
184
|
+
this._spanFanout = new FanoutSpanProcessor();
|
|
145
185
|
this._tracerProvider = new sdk_trace_node_1.NodeTracerProvider({
|
|
146
186
|
resource,
|
|
147
|
-
spanProcessors: [spanProcessor],
|
|
187
|
+
spanProcessors: [spanProcessor, this._spanFanout],
|
|
148
188
|
});
|
|
149
189
|
const logExporter = new exporter_logs_otlp_proto_1.OTLPLogExporter({
|
|
150
190
|
url: `${this._endpoint}/v1/logs`,
|
|
151
191
|
headers,
|
|
152
192
|
});
|
|
153
|
-
const logProcessor = new sdk_logs_1.BatchLogRecordProcessor(
|
|
193
|
+
const logProcessor = new sdk_logs_1.BatchLogRecordProcessor({
|
|
194
|
+
exporter: logExporter,
|
|
154
195
|
maxQueueSize: 10000,
|
|
155
196
|
maxExportBatchSize: 100,
|
|
156
197
|
scheduledDelayMillis: 1000,
|
|
157
198
|
});
|
|
158
|
-
this.
|
|
159
|
-
this._loggerProvider.
|
|
199
|
+
this._logFanout = new FanoutLogRecordProcessor();
|
|
200
|
+
this._loggerProvider = new sdk_logs_1.LoggerProvider({
|
|
201
|
+
resource,
|
|
202
|
+
processors: [logProcessor, this._logFanout],
|
|
203
|
+
});
|
|
160
204
|
this._initialized = true;
|
|
161
205
|
this._shutdownHook = () => {
|
|
162
206
|
void this.shutdown();
|
|
@@ -176,6 +220,8 @@ class StructSDK {
|
|
|
176
220
|
this._initialized = false;
|
|
177
221
|
this._tracerProvider = undefined;
|
|
178
222
|
this._loggerProvider = undefined;
|
|
223
|
+
this._spanFanout = undefined;
|
|
224
|
+
this._logFanout = undefined;
|
|
179
225
|
this._shutdownHook = undefined;
|
|
180
226
|
this._readyPromise = Promise.resolve();
|
|
181
227
|
return;
|
|
@@ -186,24 +232,24 @@ class StructSDK {
|
|
|
186
232
|
if (!this._tracerProvider) {
|
|
187
233
|
throw new Error("Call struct.init() before using the SDK");
|
|
188
234
|
}
|
|
189
|
-
return this._tracerProvider.getTracer(name);
|
|
235
|
+
return this._tracerProvider.getTracer(name, version_js_1.SDK_VERSION);
|
|
190
236
|
}
|
|
191
237
|
getLogger(name = "struct-sdk") {
|
|
192
238
|
if (!this._loggerProvider) {
|
|
193
239
|
throw new Error("Call struct.init() before using the SDK");
|
|
194
240
|
}
|
|
195
|
-
return this._loggerProvider.getLogger(name);
|
|
241
|
+
return this._loggerProvider.getLogger(name, version_js_1.SDK_VERSION);
|
|
196
242
|
}
|
|
197
243
|
getInternalLogger() {
|
|
198
244
|
return this._internalLogger;
|
|
199
245
|
}
|
|
200
246
|
/** Test-only: attach a custom span processor to the internal provider. */
|
|
201
247
|
addSpanProcessor(processor) {
|
|
202
|
-
this.
|
|
248
|
+
this._spanFanout?.delegates.push(processor);
|
|
203
249
|
}
|
|
204
250
|
/** Test-only: attach a custom log record processor. */
|
|
205
251
|
addLogRecordProcessor(processor) {
|
|
206
|
-
this.
|
|
252
|
+
this._logFanout?.delegates.push(processor);
|
|
207
253
|
}
|
|
208
254
|
/**
|
|
209
255
|
* Shut down the SDK and flush pending telemetry.
|
|
@@ -260,8 +306,7 @@ class StructSDK {
|
|
|
260
306
|
return await fn();
|
|
261
307
|
}
|
|
262
308
|
const agentName = options.name;
|
|
263
|
-
const
|
|
264
|
-
const parentSessionId = (0, context_js_1.getSessionId)();
|
|
309
|
+
const explicitSessionId = options.sessionId;
|
|
265
310
|
const tracer = this.getTracer("struct-sdk");
|
|
266
311
|
// Parent the new agent span on the nearest enclosing agent span (if
|
|
267
312
|
// any) so nested `struct.agent()` / `struct.tool()` calls build a
|
|
@@ -270,16 +315,68 @@ class StructSDK {
|
|
|
270
315
|
// nothing to hang off. We do NOT rely on the global OTel context
|
|
271
316
|
// manager being installed — the SDK manages its own parent chain via
|
|
272
317
|
// StructContext ALS to stay neutral in multi-tenant OTel setups.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
318
|
+
//
|
|
319
|
+
// Captured BEFORE we resolve/overwrite anything below — mirrors
|
|
320
|
+
// python's `enclosing_session_id` / `enclosing_agent_span` capture at
|
|
321
|
+
// core.py:625-626. Used to (1) resolve this agent's own session id via
|
|
322
|
+
// ambient inheritance, (2) detect break-out, and (3) decide whether to
|
|
323
|
+
// stamp `struct.agent.parent_session_id`.
|
|
324
|
+
const enclosingSessionId = (0, context_js_1.getSessionId)();
|
|
325
|
+
const enclosingAgentSpan = (0, context_js_1.getAgentSpan)();
|
|
326
|
+
// ── Resolve sessionId (REVISION R1 grouping model) ──────────────────
|
|
327
|
+
// Resolution order: explicit caller arg > ambient (enclosing agent) >
|
|
328
|
+
// session-less. We never fabricate a uuid — a caller that omits
|
|
329
|
+
// sessionId with no enclosing agent gets a SESSION-LESS run (no
|
|
330
|
+
// `gen_ai.conversation.id` attribute at all). Ports core.py:628-644
|
|
331
|
+
// exactly (`self._session_id = ""` there is `undefined` here).
|
|
332
|
+
const sessionId = explicitSessionId !== undefined
|
|
333
|
+
? explicitSessionId
|
|
334
|
+
: enclosingSessionId !== undefined
|
|
335
|
+
? enclosingSessionId
|
|
336
|
+
: undefined;
|
|
337
|
+
// ── Break-out detection (spawned-by Link) ───────────────────────────
|
|
338
|
+
// Caller supplied an EXPLICIT session id that differs from the
|
|
339
|
+
// enclosing agent's session AND there IS an enclosing Struct agent
|
|
340
|
+
// span in scope. This agent starts a fresh ROOT trace (no OTel
|
|
341
|
+
// parent) and carries a causal Link back to the enclosing agent's
|
|
342
|
+
// span context. Ports struct_sdk.core._AgentContext._start_span's
|
|
343
|
+
// `break_out` branch (struct-sdk-python/src/struct_sdk/core.py:651-656)
|
|
344
|
+
// exactly.
|
|
345
|
+
const breakOut = explicitSessionId !== undefined &&
|
|
346
|
+
enclosingSessionId !== undefined &&
|
|
347
|
+
explicitSessionId !== enclosingSessionId &&
|
|
348
|
+
enclosingAgentSpan !== undefined;
|
|
349
|
+
// ── Parentage: PURE NEST (Option D) ─────────────────────────────────
|
|
350
|
+
// A non-break-out agent nests under whatever OTel span is active — the
|
|
351
|
+
// host's ambient context — like every peer LLM/agent SDK. We do NOT
|
|
352
|
+
// second-guess the active span or unilaterally re-root: the old
|
|
353
|
+
// `foreignRoot` heuristic detached from ANY active span and so ripped
|
|
354
|
+
// agents out of legitimate host request traces. Cross-conversation
|
|
355
|
+
// grouping is carried by gen_ai.conversation.id, never by trace
|
|
356
|
+
// parentage. A runtime that propagates a stale/unwanted span across a
|
|
357
|
+
// boundary (e.g. our own persistent_agent Temporal workflow span) clears
|
|
358
|
+
// it at the SOURCE, never here. (A provenance-gated detach — re-root only
|
|
359
|
+
// a leaked Struct-OWNED span — is a documented, deferred follow-up.)
|
|
360
|
+
let startContext;
|
|
361
|
+
let links;
|
|
362
|
+
if (breakOut) {
|
|
363
|
+
// Explicit different session under another Struct agent: fresh ROOT
|
|
364
|
+
// trace + a spawned-by Link. The one deliberate, opt-in re-root.
|
|
365
|
+
// enclosingAgentSpan is guaranteed defined by the breakOut guard above.
|
|
366
|
+
links = [{ context: enclosingAgentSpan.spanContext() }];
|
|
367
|
+
startContext = api_1.ROOT_CONTEXT;
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
startContext = enclosingAgentSpan
|
|
371
|
+
? api_1.trace.setSpan(api_1.context.active(), enclosingAgentSpan)
|
|
372
|
+
: api_1.context.active();
|
|
373
|
+
}
|
|
277
374
|
// Span creation itself can fail (custom tracer, broken context). If it
|
|
278
375
|
// does, fall through to running `fn` directly so the user's call
|
|
279
376
|
// always runs uninstrumented rather than blowing up on telemetry.
|
|
280
377
|
let span;
|
|
281
378
|
safe(() => {
|
|
282
|
-
span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: api_1.SpanKind.INTERNAL },
|
|
379
|
+
span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: api_1.SpanKind.INTERNAL, links }, startContext);
|
|
283
380
|
}, "agent.start_span", this._internalLogger);
|
|
284
381
|
if (span === undefined) {
|
|
285
382
|
return await fn();
|
|
@@ -300,17 +397,24 @@ class StructSDK {
|
|
|
300
397
|
startedSpan.setAttribute(semconv_js_1.GEN_AI.AGENT_VERSION, options.version);
|
|
301
398
|
}
|
|
302
399
|
// gen_ai.conversation.id is the spec-blessed name for
|
|
303
|
-
// session/thread id.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
400
|
+
// session/thread id. Omitted when session-less (never fabricated).
|
|
401
|
+
// Ports core.py:723-725 (`if self._session_id:`) exactly.
|
|
402
|
+
if (sessionId) {
|
|
403
|
+
startedSpan.setAttribute(semconv_js_1.GEN_AI.CONVERSATION_ID, sessionId);
|
|
404
|
+
}
|
|
405
|
+
// ``struct.agent.parent_session_id`` is a SPAWNED-BY marker — set it
|
|
406
|
+
// ONLY when this agent has an enclosing session that DIFFERS from
|
|
407
|
+
// its own resolved session id. An inline nested agent (same session
|
|
408
|
+
// as the enclosing agent, whether by explicit same id or ambient
|
|
409
|
+
// inheritance) already has its parent relationship encoded by the
|
|
410
|
+
// OTel span tree (ParentSpanId) — stamping
|
|
411
|
+
// parent_session_id === own sessionId would be self-referential
|
|
412
|
+
// noise. Structure comes from the tree/Link, not this attr
|
|
413
|
+
// (Link-canonical decision). Ports core.py:736-745 exactly (both
|
|
414
|
+
// the break-out and the "legacy" differing-id-no-span branches
|
|
415
|
+
// collapse to this one condition).
|
|
416
|
+
if (enclosingSessionId !== undefined && enclosingSessionId !== sessionId) {
|
|
417
|
+
startedSpan.setAttribute(semconv_js_1.STRUCT.AGENT_PARENT_SESSION_ID, enclosingSessionId);
|
|
314
418
|
}
|
|
315
419
|
if (options.metadata) {
|
|
316
420
|
for (const [key, value] of Object.entries(options.metadata)) {
|
|
@@ -323,6 +427,7 @@ class StructSDK {
|
|
|
323
427
|
conversationId: sessionId,
|
|
324
428
|
agentSpan: startedSpan,
|
|
325
429
|
pendingToolCalls: {},
|
|
430
|
+
manualAgentSpan: startedSpan,
|
|
326
431
|
}, async () => {
|
|
327
432
|
const activeCtx = api_1.trace.setSpan(api_1.context.active(), startedSpan);
|
|
328
433
|
try {
|
package/dist/commonjs/events.js
CHANGED
|
@@ -53,7 +53,7 @@ function emitAnthropicMessageEvents(logger, messages, system) {
|
|
|
53
53
|
parts: (0, truncation_js_1.truncateParts)(parts),
|
|
54
54
|
}),
|
|
55
55
|
extraAttrs: {
|
|
56
|
-
[semconv_js_1.GEN_AI.
|
|
56
|
+
[semconv_js_1.GEN_AI.PROVIDER_NAME]: "anthropic",
|
|
57
57
|
[semconv_js_1.GEN_AI.MESSAGE_INDEX]: msgIndex,
|
|
58
58
|
},
|
|
59
59
|
});
|
|
@@ -71,7 +71,7 @@ function emitAnthropicMessageEvents(logger, messages, system) {
|
|
|
71
71
|
eventName,
|
|
72
72
|
payload: (0, truncation_js_1.safeJsonStringify)({ role, parts: (0, truncation_js_1.truncateParts)(parts) }),
|
|
73
73
|
extraAttrs: {
|
|
74
|
-
[semconv_js_1.GEN_AI.
|
|
74
|
+
[semconv_js_1.GEN_AI.PROVIDER_NAME]: "anthropic",
|
|
75
75
|
[semconv_js_1.GEN_AI.MESSAGE_INDEX]: msgIndex,
|
|
76
76
|
},
|
|
77
77
|
});
|
|
@@ -96,7 +96,7 @@ function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason) {
|
|
|
96
96
|
eventName: semconv_js_1.EVENT_NAMES.CHOICE,
|
|
97
97
|
payload,
|
|
98
98
|
extraAttrs: {
|
|
99
|
-
[semconv_js_1.GEN_AI.
|
|
99
|
+
[semconv_js_1.GEN_AI.PROVIDER_NAME]: "anthropic",
|
|
100
100
|
},
|
|
101
101
|
});
|
|
102
102
|
}
|
|
@@ -22,6 +22,8 @@ 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
|
+
export declare function wrapCreate(original: CreateMethod): CreateMethod;
|
|
26
|
+
export declare function wrapStream(original: StreamMethod): StreamMethod;
|
|
25
27
|
/** @internal */
|
|
26
28
|
export type _PatchContextForTest = PatchContext;
|
|
27
29
|
/** @internal */
|