@struct-ai/sdk
v0.4.3
Published
Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
Maintainers
Readme
@struct-ai/sdk
Struct agent observability SDK for TypeScript/Node.js. Auto-instruments AI agent frameworks and LLM SDKs — the Anthropic SDK, the OpenAI SDK (Responses API), and LangChain.js — and emits OpenTelemetry traces + logs to struct.ai with zero config.
This is the TypeScript port of struct-sdk
(Python). Span names, attribute keys, and log event shapes are identical across
the two SDKs so the server processes both uniformly.
Install
npm install @struct-ai/sdk
# optional — the SDK auto-instruments these if present
npm install @anthropic-ai/sdk openai @langchain/core @langchain/langgraphRequires Node 18+.
Quickstart
Get an ingest key from app.struct.ai/settings?tab=ingest-keys, then:
import { struct } from "@struct-ai/sdk";
// Initialize once, as early as possible in your process
struct.init({
ingestKey: process.env.STRUCT_INGEST_KEY!, // or pass the string directly
serviceName: "my-agent",
environment: "production",
});
// Use your agent code as normal — spans + log events are emitted automatically.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
await struct.agent({ name: "checkout" }, async () => {
const msg = await client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [{ role: "user", content: "plan my checkout flow" }],
});
// tool_call_id is auto-filled from the preceding Anthropic response
await struct.tool({ name: "search" }, async () => {
return await search(msg);
});
});What gets instrumented
| Library | Hook | Span type | Notes |
|---|---|---|---|
| @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 |
| @anthropic-ai/bedrock-sdk, @anthropic-ai/vertex-sdk | Messages.prototype.* | chat {model} | Best-effort, if installed |
| openai | Responses.prototype.create | chat {model} | Responses API, non-streaming calls only — responses.create({stream: true}) and responses.stream() pass through untouched (no span), and chat.completions is not instrumented. Cache-read/cache-write/reasoning token accounting; function_call → struct.tool() id auto-linkage. Requires an openai release with the Responses API (v4 releases without it are a graceful no-op). Defers to a LangChain call in progress, same as Anthropic |
| @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 §5) |
| @langchain/core StructuredTool | .invoke | execute_tool {name} | Extracts tool_call_id from LangChain ToolCall input or pending queue |
| @langchain/core BaseRetriever | .invoke | retrieval {name} | |
| @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 }, ...) — the struct-native replacement for LangSmith's tracing_context(parent=run_tree). |
Framework integration
struct.init() takes the same options regardless of which framework
you're instrumenting. Required: ingestKey (get one at
app.struct.ai/settings?tab=ingest-keys).
Recommended: serviceName, environment.
What you need to do beyond init() depends on whether you're using an
agent framework (which has built-in concepts of agents and tools) or
an LLM SDK directly (which only knows about chat completions). The
SDK auto-instruments both, but only agent frameworks get full agent +
tool spans for free — when you call an LLM SDK directly, you have to
tell the SDK where the agent and tool boundaries are.
Call init() once, as early as possible, before the instrumented
libraries are imported, so their prototypes are patched before any
instance is constructed.
Agent frameworks — fully auto-instrumented
For these, calling struct.init() is the only setup. Agent, tool, chat,
and retrieval spans all emit automatically.
LangChain / LangGraph (with an agent or graph)
import { struct } from "@struct-ai/sdk";
struct.init({ ingestKey: "pk-...", serviceName: "my-graph" });
import { createReactAgent } from "@langchain/langgraph/prebuilt";
// Pregel invocations get invoke_agent spans. BaseChatModel calls get
// chat spans. StructuredTool.invoke gets execute_tool spans.
// BaseRetriever.invoke gets retrieval spans.Recommended pattern: wrap LangChain entry points in struct.agent
For multi-turn HTTP-style usage (every request continues the same
conversation), wrap your request handler in
struct.agent({ sessionId: conversationId }, async () => { ... }).
This is the struct-native replacement for with ls.tracing_context(parent=run_tree):
and gives you two things you can't get from configurable.thread_id alone:
- Threading without per-call config plumbing. Every nested
LangChain call inherits the conversation id via the SDK's ambient
AsyncLocalStorage — you don't have to ensure each
compiledGraph.invokegetsthread_idon its config. - One trace per request.
struct.agentcreates a parent OTel span so all the LangChain work for the request nests under one trace (clean tree, "Subagents" / "Spawned by" UI links work). Without it, each.invoke()becomes its own root trace, and the UI's session list shows a non-deterministic agent name (omni_agent,LangGraph, the first sub-agent it sees…).
Migrating from LangSmith:
// Before — LangSmith convention, fragments under struct-sdk
await ls.traceable(async () => {
await orchestrator.invoke(inputs, config);
}, { parent: runTree })();
// After — struct-native, threads correctly, no langsmith dep
await struct.agent({ sessionId: conversationId }, async () => {
await orchestrator.invoke(inputs, config);
});LLM SDKs used directly — manual agent + tool scopes required
When you call an LLM SDK directly (no agent framework wrapping it), only
chat spans emit automatically. You need to wrap your agent loop in
struct.agent() and each tool execution in struct.tool() so the SDK
knows where to put the agent and tool boundaries — otherwise you'll see
free-floating chat spans with no agent or tool context around them.
Anthropic SDK (raw)
import { struct } from "@struct-ai/sdk";
struct.init({ ingestKey: "pk-...", serviceName: "checkout-agent" });
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// Required: wrap the agent loop yourself.
await struct.agent({ name: "checkout" }, async () => {
const msg = await client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [...],
});
// Required: wrap each tool execution.
// tool_call_id is auto-filled from the preceding Anthropic response.
await struct.tool({ name: "search" }, async () => {
return await search(...);
});
});@anthropic-ai/sdk, @anthropic-ai/bedrock-sdk, and @anthropic-ai/vertex-sdk
are all auto-instrumented for chat spans.
OpenAI SDK (raw, Responses API)
Same rule as raw Anthropic — chat spans emit automatically; wrap your agent loop and tools yourself:
import { struct } from "@struct-ai/sdk";
struct.init({ ingestKey: "pk-...", serviceName: "support-agent" });
import OpenAI from "openai";
const client = new OpenAI();
await struct.agent({ name: "support" }, async () => {
const resp = await client.responses.create({
model: "gpt-5.5",
input: "triage this ticket",
});
// tool_call_id is auto-filled from the preceding response's function_call.
await struct.tool({ name: "lookup_order" }, async () => {
return await lookupOrder(resp);
});
});Scope: the OpenAI integration covers non-streaming responses.create()
only. Streaming calls (stream: true / responses.stream()) run unmodified and
emit no span, and chat.completions is not instrumented. Azure OpenAI clients
(AzureOpenAI) share the same Responses class and are covered by the same
patch.
LangChain BaseChatModel (no agent/graph)
If you call ChatAnthropic.invoke(...) (or any other BaseChatModel)
without wrapping it in AgentExecutor or a LangGraph graph, only the chat
span emits automatically. Same rule as raw Anthropic — wrap your agent
loop in struct.agent() and tool execution in struct.tool().
import { struct } from "@struct-ai/sdk";
struct.init({ ingestKey: "pk-...", serviceName: "my-agent" });
import { ChatAnthropic } from "@langchain/anthropic";
const llm = new ChatAnthropic({ model: "claude-3-5-sonnet-20241022" });
await struct.agent({ name: "my-agent" }, async () => {
const response = await llm.invoke([["user", "..."]]);
await struct.tool({ name: "search" }, async () => {
// ...
});
});When you do use ChatAnthropic and have @anthropic-ai/sdk installed,
the chat span comes from the LangChain layer (single span); the Anthropic
patch detects the framework already owns the call and defers.
Content capture
The SDK supports four capture modes controlling how prompt/response content is emitted.
import { struct, ContentCaptureMode } from "@struct-ai/sdk";
struct.init({
ingestKey: ...,
contentCapture: ContentCaptureMode.EventOnly, // default
// or ContentCaptureMode.None, SpanOnly, SpanAndEvent
});EventOnly(default): per-message content lands on OTel log records (gen_ai.{user,assistant,system,tool}.message,gen_ai.choice).SpanOnly: content on span attributes (gen_ai.input.messages,gen_ai.output.messages).SpanAndEvent: both.None: no content captured, anywhere — no log events, no span content attributes. Token counts, tool call IDs, finish reasons, and other metadata still flow.
Set captureContent: false for the legacy bool API (equivalent to
ContentCaptureMode.None).
Manual scopes
struct.agent() and struct.tool() create invoke_agent and execute_tool
spans. These are optional — LangChain's Pregel patch creates agent spans
automatically when your graph has a thread_id in the config.
await struct.agent(
{ name: "onboarding", sessionId: conversationId, metadata: { tenant: "acme" } },
async () => {
await struct.tool({ name: "fetch-profile" }, async () => {
return fetchProfile();
});
}
);Sub-agents (e.g. a createReactAgent graph invoked from inside another
agent's tool body) record their parent via the
struct.agent.parent_session_id attribute on the inner invoke_agent
span. This powers the UI's "Spawned by" backlink, which works for any
nested invocation.
The parent's "Subagents" forward list — the inverse direction — requires
that the nested invoke inherits the parent's trace via LangChain's
callback chain. This works automatically when the outer tool is built
with the tool() factory from @langchain/core/tools; it does not
work with new DynamicTool({...}), which skips the callback wrap. See
the troubleshooting section below for the workaround.
Semantic conventions
Emits attributes per the OTel GenAI semantic conventions:
gen_ai.operation.name—chat,execute_tool,invoke_agent,retrievalgen_ai.provider.name— the GenAI provider on inference spans (anthropic,openai, …); platform-routed calls report the platform value (aws.bedrock,gcp.vertex_ai,azure.ai.openai) when detectable from the client.invoke_agentspans inherit the provider from their first child inference call (omitted when no model is reached);execute_tool/retrievalspans carry no provider.gen_ai.request.{model, max_tokens, temperature, top_p, top_k, stop_sequences}gen_ai.response.{model, id, finish_reasons}gen_ai.usage.{input_tokens, output_tokens, cache_read.input_tokens, cache_creation.input_tokens}gen_ai.conversation.idgen_ai.tool.{name, call.id, call.arguments, call.result}error.type+StatusCode.ERRORon failures
error.type values emitted
The OTel conventions ask instrumentations to document the error values they
report ("Instrumentations SHOULD document the list of errors they report").
This SDK emits exactly two kinds of value, both alongside span
StatusCode.ERROR:
| Value | When | Meaning |
| --- | --- | --- |
| exception class name (e.g. TypeError, APIConnectionError) | the instrumented call threw | We failed to execute the request. Also records an OTel exception event. |
| tool_error | an execute_tool span whose result signalled failure in band — Anthropic tool_result blocks with is_error: true, MCP CallToolResult.isError, or a LangChain ToolMessage with status: "error" | The tool ran and reported a failure back to the model (bad arguments, a domain "no"), so the model can self-correct. No exception object exists, so no class name is available. |
tool_error is a deliberate low-cardinality sentinel, which the error.type
convention explicitly permits ("another low-cardinality error identifier"; a
custom value MAY be used where no well-known one applies). The same split is
used by OpenTelemetry's MCP instrumentation in OpenLLMetry, which likewise
reports error.type="tool_error" for the isError path and the exception
class name otherwise. Matches the Python SDK.
The distinction is what lets a monitor page on genuine execution failures while excluding failures the model already saw and can recover from.
Note: gen_ai.usage.input_tokens for Anthropic is the TRUE total — we add back
cache_read_input_tokens + cache_creation_input_tokens (which Anthropic's raw
response excludes). Matches the Python SDK.
Development
pnpm test— unit + e2e tests (mocked, no network, no API keys). Excludestest/live/**.pnpm typecheck—tsc --noEmit.pnpm build—tshy, producing the dual ESM/CJSdist/.pnpm test:live— the live real-model suite: real provider SDKs (@anthropic-ai/sdk,openai) and frameworks (@langchain/*) against the real Anthropic / OpenAI APIs, verifying the emitted spans + log events in-memory (no ingester needed). Each gated block skips cleanly (exit 0) when its API key is absent — safe to leave in normal CI.To run it, point
STRUCT_LIVE_ENV_FILEat a file with the keys you want to exercise (ANTHROPIC_API_KEY=...and/orOPENAI_API_KEY=...; dotenv-styleKEY=valuelines, quotes optional). The path is never committed — it's supplied per-invocation:STRUCT_LIVE_ENV_FILE=/path/to/your/.env pnpm test:liveOnly blocks whose key is present run, so a file with both keys runs both the Anthropic and OpenAI suites (a handful of small real calls each — cheap model aliases, small token budgets).
Full-pipeline e2e (
test/live/openai-ingest-e2e.live.test.ts) additionally ships the emitted telemetry to a real ingest endpoint and flushes it, so you can confirm a change travels the whole path (SDK → OTLP export → your Struct instance). It's gated onSTRUCT_INGEST_URLSTRUCT_INGEST_KEY(on top ofOPENAI_API_KEY), so it stays skipped unless you deliberately supply real ingest credentials. It still self-asserts the span shape in-memory; confirming the span actually landed means querying your Struct instance's telemetry — see AGENTS.md.
pnpm test:parity— the cross-language conformance harness: drives the same canonical agent topology through both this SDK andstruct-sdk-pythonand diffs their normalized span shapes, failing (non-zero exit) on any divergence. No network calls or API keys required.
See AGENTS.md for the full set of governance rules (fault isolation, OTel citizenship, semconv conformance, the release-version-bump gate) that apply to any change in this package.
Troubleshooting
- Spans missing after instrumenting: Import
@struct-ai/sdk(orstruct.init()) before the instrumented libraries, so their class prototypes are patched before any instance is constructed. In most Node setups this is automatic, but some bundlers tree-shake aggressively. - No logs appearing:
LogRecords only emit whensdk.emitEventsis true (EventOnlyorSpanAndEventcapture mode, which is the default). If you setcaptureContent: falseyou disable them. - Duplicate chat spans: Ownership of the
chatspan is fixed (manual > framework > provider — see AGENTS.md §5): when you callChatAnthropic.invoke()/.stream(), the LangChain integration always owns thechatspan (handleChatModelStartinlangchain-callback.ts). It runs the underlying provider call inside an internal scope (suppressGenAi: true) that the direct@anthropic-ai/sdkpatch checks viaisGenAiSuppressed()— when set, the provider patch defers and emits nothing, so only the LangChain-owned span is produced. If you see doubles, the two integrations are likely observing different@anthropic-ai/sdkmodule instances (common with pnpm hoisting a nested copy under@langchain/anthropic), so the provider patch never sees the suppression scope set by the framework layer; confirm both integrations are auto-instrumenting the SAME module instance (checkstruct.initializedand your lockfile's dedupe of@anthropic-ai/sdk). - Subagent in a different trace / missing from parent's "Subagents" list:
If you invoke a nested agent (
subagent.invoke(...)) from inside a tool body, define the outer tool withtool(func, { name, description, schema })from@langchain/core/tools— notnew DynamicTool({...}). Thetool()factory wraps your function inAsyncLocalStorageProviderSingleton.runWithConfig(...), which is what lets the nested invoke inherit the tool's callback chain and share the parent's trace_id.DynamicToolskips that wrap, so the subagent starts a new trace and parent↔subagent linkage breaks. Thestruct.agent.parent_session_idattribute is still set, so "Spawned by" on the child side still renders — but the parent's forward link to the subagent won't appear.
License
Apache-2.0
