@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/dist/esm/core.js CHANGED
@@ -1,8 +1,7 @@
1
- import { randomUUID } from "node:crypto";
2
- import { context as otelContext, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
1
+ import { context as otelContext, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
3
2
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
4
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
5
- import { Resource } from "@opentelemetry/resources";
4
+ import { resourceFromAttributes } from "@opentelemetry/resources";
6
5
  import { BatchLogRecordProcessor, LoggerProvider, } from "@opentelemetry/sdk-logs";
7
6
  import { BatchSpanProcessor, NodeTracerProvider, } from "@opentelemetry/sdk-trace-node";
8
7
  import { ContentCaptureMode, captureContentFor, emitEventsFor, emitSpanContentFor, } from "./content-capture.js";
@@ -10,6 +9,7 @@ import { getAgentSpan, getSessionId, popPendingToolCallId, runWithContext, } fro
10
9
  import { autoInstrument } from "./integrations/index.js";
11
10
  import { ERROR_TYPE, GEN_AI, STRUCT, } from "./semconv.js";
12
11
  import { safeJsonStringify } from "./truncation.js";
12
+ import { SDK_VERSION } from "./version.js";
13
13
  export const DEFAULT_ENDPOINT = "https://ingest.struct.ai";
14
14
  const LOG_ENABLED = process.env.STRUCT_SDK_LOG === "1";
15
15
  const defaultInternalLogger = {
@@ -33,6 +33,43 @@ const defaultInternalLogger = {
33
33
  * so consumers cannot reach it via the package's public API surface.
34
34
  */
35
35
  export const firstFailureLogged = new Set();
36
+ /**
37
+ * OTel SDK 2.x providers accept processors only at construction time —
38
+ * `addSpanProcessor` / `addLogRecordProcessor` no longer exist on the
39
+ * providers. These fan-outs are registered at construction and delegate to a
40
+ * mutable list, preserving the SDK's test-only `addSpanProcessor` /
41
+ * `addLogRecordProcessor` hooks.
42
+ */
43
+ class FanoutSpanProcessor {
44
+ delegates = [];
45
+ onStart(...args) {
46
+ for (const p of this.delegates)
47
+ p.onStart(...args);
48
+ }
49
+ onEnd(...args) {
50
+ for (const p of this.delegates)
51
+ p.onEnd(...args);
52
+ }
53
+ forceFlush() {
54
+ return Promise.all(this.delegates.map((p) => p.forceFlush())).then(() => undefined);
55
+ }
56
+ shutdown() {
57
+ return Promise.all(this.delegates.map((p) => p.shutdown())).then(() => undefined);
58
+ }
59
+ }
60
+ class FanoutLogRecordProcessor {
61
+ delegates = [];
62
+ onEmit(...args) {
63
+ for (const p of this.delegates)
64
+ p.onEmit(...args);
65
+ }
66
+ forceFlush() {
67
+ return Promise.all(this.delegates.map((p) => p.forceFlush())).then(() => undefined);
68
+ }
69
+ shutdown() {
70
+ return Promise.all(this.delegates.map((p) => p.shutdown())).then(() => undefined);
71
+ }
72
+ }
36
73
  /**
37
74
  * Run `fn`; swallow any exception. The first failure per `site` logs at WARN
38
75
  * (with the error attached); subsequent failures at the same site log at
@@ -60,6 +97,8 @@ export class StructSDK {
60
97
  _initialized = false;
61
98
  _tracerProvider;
62
99
  _loggerProvider;
100
+ _spanFanout;
101
+ _logFanout;
63
102
  _ingestKey = "";
64
103
  _endpoint = DEFAULT_ENDPOINT;
65
104
  _contentCapture = ContentCaptureMode.EventOnly;
@@ -133,26 +172,31 @@ export class StructSDK {
133
172
  maxExportBatchSize: 100,
134
173
  scheduledDelayMillis: 1000,
135
174
  });
136
- const resource = new Resource({
175
+ const resource = resourceFromAttributes({
137
176
  "service.name": serviceName,
138
177
  "service.version": serviceVersion,
139
178
  "deployment.environment": environment,
140
179
  });
180
+ this._spanFanout = new FanoutSpanProcessor();
141
181
  this._tracerProvider = new NodeTracerProvider({
142
182
  resource,
143
- spanProcessors: [spanProcessor],
183
+ spanProcessors: [spanProcessor, this._spanFanout],
144
184
  });
145
185
  const logExporter = new OTLPLogExporter({
146
186
  url: `${this._endpoint}/v1/logs`,
147
187
  headers,
148
188
  });
149
- const logProcessor = new BatchLogRecordProcessor(logExporter, {
189
+ const logProcessor = new BatchLogRecordProcessor({
190
+ exporter: logExporter,
150
191
  maxQueueSize: 10000,
151
192
  maxExportBatchSize: 100,
152
193
  scheduledDelayMillis: 1000,
153
194
  });
154
- this._loggerProvider = new LoggerProvider({ resource });
155
- this._loggerProvider.addLogRecordProcessor(logProcessor);
195
+ this._logFanout = new FanoutLogRecordProcessor();
196
+ this._loggerProvider = new LoggerProvider({
197
+ resource,
198
+ processors: [logProcessor, this._logFanout],
199
+ });
156
200
  this._initialized = true;
157
201
  this._shutdownHook = () => {
158
202
  void this.shutdown();
@@ -172,6 +216,8 @@ export class StructSDK {
172
216
  this._initialized = false;
173
217
  this._tracerProvider = undefined;
174
218
  this._loggerProvider = undefined;
219
+ this._spanFanout = undefined;
220
+ this._logFanout = undefined;
175
221
  this._shutdownHook = undefined;
176
222
  this._readyPromise = Promise.resolve();
177
223
  return;
@@ -182,24 +228,24 @@ export class StructSDK {
182
228
  if (!this._tracerProvider) {
183
229
  throw new Error("Call struct.init() before using the SDK");
184
230
  }
185
- return this._tracerProvider.getTracer(name);
231
+ return this._tracerProvider.getTracer(name, SDK_VERSION);
186
232
  }
187
233
  getLogger(name = "struct-sdk") {
188
234
  if (!this._loggerProvider) {
189
235
  throw new Error("Call struct.init() before using the SDK");
190
236
  }
191
- return this._loggerProvider.getLogger(name);
237
+ return this._loggerProvider.getLogger(name, SDK_VERSION);
192
238
  }
193
239
  getInternalLogger() {
194
240
  return this._internalLogger;
195
241
  }
196
242
  /** Test-only: attach a custom span processor to the internal provider. */
197
243
  addSpanProcessor(processor) {
198
- this._tracerProvider?.addSpanProcessor(processor);
244
+ this._spanFanout?.delegates.push(processor);
199
245
  }
200
246
  /** Test-only: attach a custom log record processor. */
201
247
  addLogRecordProcessor(processor) {
202
- this._loggerProvider?.addLogRecordProcessor(processor);
248
+ this._logFanout?.delegates.push(processor);
203
249
  }
204
250
  /**
205
251
  * Shut down the SDK and flush pending telemetry.
@@ -256,8 +302,7 @@ export class StructSDK {
256
302
  return await fn();
257
303
  }
258
304
  const agentName = options.name;
259
- const sessionId = options.sessionId ?? randomUUID();
260
- const parentSessionId = getSessionId();
305
+ const explicitSessionId = options.sessionId;
261
306
  const tracer = this.getTracer("struct-sdk");
262
307
  // Parent the new agent span on the nearest enclosing agent span (if
263
308
  // any) so nested `struct.agent()` / `struct.tool()` calls build a
@@ -266,16 +311,68 @@ export class StructSDK {
266
311
  // nothing to hang off. We do NOT rely on the global OTel context
267
312
  // manager being installed — the SDK manages its own parent chain via
268
313
  // StructContext ALS to stay neutral in multi-tenant OTel setups.
269
- const parentSpan = getAgentSpan();
270
- const parentCtx = parentSpan
271
- ? trace.setSpan(otelContext.active(), parentSpan)
272
- : otelContext.active();
314
+ //
315
+ // Captured BEFORE we resolve/overwrite anything below — mirrors
316
+ // python's `enclosing_session_id` / `enclosing_agent_span` capture at
317
+ // core.py:625-626. Used to (1) resolve this agent's own session id via
318
+ // ambient inheritance, (2) detect break-out, and (3) decide whether to
319
+ // stamp `struct.agent.parent_session_id`.
320
+ const enclosingSessionId = getSessionId();
321
+ const enclosingAgentSpan = getAgentSpan();
322
+ // ── Resolve sessionId (REVISION R1 grouping model) ──────────────────
323
+ // Resolution order: explicit caller arg > ambient (enclosing agent) >
324
+ // session-less. We never fabricate a uuid — a caller that omits
325
+ // sessionId with no enclosing agent gets a SESSION-LESS run (no
326
+ // `gen_ai.conversation.id` attribute at all). Ports core.py:628-644
327
+ // exactly (`self._session_id = ""` there is `undefined` here).
328
+ const sessionId = explicitSessionId !== undefined
329
+ ? explicitSessionId
330
+ : enclosingSessionId !== undefined
331
+ ? enclosingSessionId
332
+ : undefined;
333
+ // ── Break-out detection (spawned-by Link) ───────────────────────────
334
+ // Caller supplied an EXPLICIT session id that differs from the
335
+ // enclosing agent's session AND there IS an enclosing Struct agent
336
+ // span in scope. This agent starts a fresh ROOT trace (no OTel
337
+ // parent) and carries a causal Link back to the enclosing agent's
338
+ // span context. Ports struct_sdk.core._AgentContext._start_span's
339
+ // `break_out` branch (struct-sdk-python/src/struct_sdk/core.py:651-656)
340
+ // exactly.
341
+ const breakOut = explicitSessionId !== undefined &&
342
+ enclosingSessionId !== undefined &&
343
+ explicitSessionId !== enclosingSessionId &&
344
+ enclosingAgentSpan !== undefined;
345
+ // ── Parentage: PURE NEST (Option D) ─────────────────────────────────
346
+ // A non-break-out agent nests under whatever OTel span is active — the
347
+ // host's ambient context — like every peer LLM/agent SDK. We do NOT
348
+ // second-guess the active span or unilaterally re-root: the old
349
+ // `foreignRoot` heuristic detached from ANY active span and so ripped
350
+ // agents out of legitimate host request traces. Cross-conversation
351
+ // grouping is carried by gen_ai.conversation.id, never by trace
352
+ // parentage. A runtime that propagates a stale/unwanted span across a
353
+ // boundary (e.g. our own persistent_agent Temporal workflow span) clears
354
+ // it at the SOURCE, never here. (A provenance-gated detach — re-root only
355
+ // a leaked Struct-OWNED span — is a documented, deferred follow-up.)
356
+ let startContext;
357
+ let links;
358
+ if (breakOut) {
359
+ // Explicit different session under another Struct agent: fresh ROOT
360
+ // trace + a spawned-by Link. The one deliberate, opt-in re-root.
361
+ // enclosingAgentSpan is guaranteed defined by the breakOut guard above.
362
+ links = [{ context: enclosingAgentSpan.spanContext() }];
363
+ startContext = ROOT_CONTEXT;
364
+ }
365
+ else {
366
+ startContext = enclosingAgentSpan
367
+ ? trace.setSpan(otelContext.active(), enclosingAgentSpan)
368
+ : otelContext.active();
369
+ }
273
370
  // Span creation itself can fail (custom tracer, broken context). If it
274
371
  // does, fall through to running `fn` directly so the user's call
275
372
  // always runs uninstrumented rather than blowing up on telemetry.
276
373
  let span;
277
374
  safe(() => {
278
- span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: SpanKind.INTERNAL }, parentCtx);
375
+ span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: SpanKind.INTERNAL, links }, startContext);
279
376
  }, "agent.start_span", this._internalLogger);
280
377
  if (span === undefined) {
281
378
  return await fn();
@@ -296,17 +393,24 @@ export class StructSDK {
296
393
  startedSpan.setAttribute(GEN_AI.AGENT_VERSION, options.version);
297
394
  }
298
395
  // gen_ai.conversation.id is the spec-blessed name for
299
- // session/thread id.
300
- startedSpan.setAttribute(GEN_AI.CONVERSATION_ID, sessionId);
301
- // Always set parent_session_id when there's a parent agent — even
302
- // when the value matches sessionId (which happens when a nested
303
- // ``struct.agent()`` inherits ambient session). The attribute is
304
- // structural ("this agent has a parent agent"), not a uniqueness
305
- // marker. The UI uses it to render an inline subagent expansion
306
- // under the triggering call (vs the drill-in flow used when
307
- // sessionIds differ).
308
- if (parentSessionId) {
309
- startedSpan.setAttribute(STRUCT.AGENT_PARENT_SESSION_ID, parentSessionId);
396
+ // session/thread id. Omitted when session-less (never fabricated).
397
+ // Ports core.py:723-725 (`if self._session_id:`) exactly.
398
+ if (sessionId) {
399
+ startedSpan.setAttribute(GEN_AI.CONVERSATION_ID, sessionId);
400
+ }
401
+ // ``struct.agent.parent_session_id`` is a SPAWNED-BY marker — set it
402
+ // ONLY when this agent has an enclosing session that DIFFERS from
403
+ // its own resolved session id. An inline nested agent (same session
404
+ // as the enclosing agent, whether by explicit same id or ambient
405
+ // inheritance) already has its parent relationship encoded by the
406
+ // OTel span tree (ParentSpanId) — stamping
407
+ // parent_session_id === own sessionId would be self-referential
408
+ // noise. Structure comes from the tree/Link, not this attr
409
+ // (Link-canonical decision). Ports core.py:736-745 exactly (both
410
+ // the break-out and the "legacy" differing-id-no-span branches
411
+ // collapse to this one condition).
412
+ if (enclosingSessionId !== undefined && enclosingSessionId !== sessionId) {
413
+ startedSpan.setAttribute(STRUCT.AGENT_PARENT_SESSION_ID, enclosingSessionId);
310
414
  }
311
415
  if (options.metadata) {
312
416
  for (const [key, value] of Object.entries(options.metadata)) {
@@ -319,6 +423,7 @@ export class StructSDK {
319
423
  conversationId: sessionId,
320
424
  agentSpan: startedSpan,
321
425
  pendingToolCalls: {},
426
+ manualAgentSpan: startedSpan,
322
427
  }, async () => {
323
428
  const activeCtx = trace.setSpan(otelContext.active(), startedSpan);
324
429
  try {
@@ -49,7 +49,7 @@ export function emitAnthropicMessageEvents(logger, messages, system) {
49
49
  parts: truncateParts(parts),
50
50
  }),
51
51
  extraAttrs: {
52
- [GEN_AI.SYSTEM]: "anthropic",
52
+ [GEN_AI.PROVIDER_NAME]: "anthropic",
53
53
  [GEN_AI.MESSAGE_INDEX]: msgIndex,
54
54
  },
55
55
  });
@@ -67,7 +67,7 @@ export function emitAnthropicMessageEvents(logger, messages, system) {
67
67
  eventName,
68
68
  payload: safeJsonStringify({ role, parts: truncateParts(parts) }),
69
69
  extraAttrs: {
70
- [GEN_AI.SYSTEM]: "anthropic",
70
+ [GEN_AI.PROVIDER_NAME]: "anthropic",
71
71
  [GEN_AI.MESSAGE_INDEX]: msgIndex,
72
72
  },
73
73
  });
@@ -92,7 +92,7 @@ export function emitAnthropicChoiceEvent(logger, contentBlocks, stopReason) {
92
92
  eventName: EVENT_NAMES.CHOICE,
93
93
  payload,
94
94
  extraAttrs: {
95
- [GEN_AI.SYSTEM]: "anthropic",
95
+ [GEN_AI.PROVIDER_NAME]: "anthropic",
96
96
  },
97
97
  });
98
98
  }
@@ -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 */