@struct-ai/sdk
Struct agent observability SDK for TypeScript/Node.js. Auto-instruments AI agent
frameworks — the Anthropic SDK 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
npm install @anthropic-ai/sdk @langchain/core @langchain/langgraph
Requires Node 18+.
Quickstart
Get an ingest key from
app.struct.ai/settings?tab=ingest-keys,
then:
import { struct } from "@struct-ai/sdk";
struct.init({
ingestKey: process.env.STRUCT_INGEST_KEY!,
serviceName: "my-agent",
environment: "production",
});
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" }],
});
await struct.tool({ name: "search" }, async () => {
return await search(msg);
});
});
What gets instrumented
@anthropic-ai/sdk | Messages.prototype.create, .stream | chat {model} | Cache-token accounting, streaming with tool-use reconstruction |
@anthropic-ai/bedrock-sdk, @anthropic-ai/vertex-sdk | Messages.prototype.* | chat {model} | Best-effort, if installed |
@langchain/core BaseChatModel | .invoke, .stream | chat {model} | Skipped when a provider-direct instrumentor is active (e.g. ChatAnthropic + Anthropic patch → single span) |
@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";
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.invoke
gets thread_id on its config.
- One trace per request.
struct.agent creates 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:
await ls.traceable(async () => {
await orchestrator.invoke(inputs, config);
}, { parent: runTree })();
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();
await struct.agent({ name: "checkout" }, async () => {
const msg = await client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [...],
});
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.
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 Anthropic patch (single span); the LangChain
layer suppresses its duplicate.
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,
});
EventOnly (default): per-message content lands on OTel log records
(gen_ai.{user,assistant,system,tool}.message, gen_ai.choice). Spans carry
metadata only.
SpanOnly: content on span attributes (gen_ai.input.messages,
gen_ai.output.messages).
SpanAndEvent: both.
None: no content captured. 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, retrieval
gen_ai.provider.name — anthropic, openai, langchain, struct, …
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.id
gen_ai.tool.{name, call.id, call.arguments, call.result}
error.type + StatusCode.ERROR on failures
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.
Troubleshooting
- Spans missing after instrumenting: Import
@struct-ai/sdk (or struct.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 when sdk.emitEvents is true
(EventOnly or SpanAndEvent capture mode, which is the default). If you set
captureContent: false you disable them.
- Duplicate chat spans: The LangChain integration suppresses its own chat
span when a provider-direct patch is active (e.g. ChatAnthropic calls through
to
@anthropic-ai/sdk, which emits its own chat span). If you see doubles,
confirm both integrations are auto-instrumenting (check struct.initialized).
- 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 with tool(func, { name, description, schema }) from
@langchain/core/tools — not new DynamicTool({...}). The tool() factory
wraps your function in AsyncLocalStorageProviderSingleton.runWithConfig(...),
which is what lets the nested invoke inherit the tool's callback chain and
share the parent's trace_id. DynamicTool skips that wrap, so the subagent
starts a new trace and parent↔subagent linkage breaks. The struct.agent.parent_session_id
attribute 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