@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
npm install @anthropic-ai/sdk openai @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. 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";
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.
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",
});
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,
});
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, retrieval
gen_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_agent spans inherit the provider from their first child inference call (omitted when no model is reached); execute_tool/retrieval spans 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.id
gen_ai.tool.{name, call.id, call.arguments, call.result}
error.type + StatusCode.ERROR on 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:
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). Excludes
test/live/**.
-
pnpm typecheck — tsc --noEmit.
-
pnpm build — tshy, producing the dual ESM/CJS dist/.
-
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_FILE at a file with the keys you want to
exercise (ANTHROPIC_API_KEY=... and/or OPENAI_API_KEY=...; dotenv-style
KEY=value lines, quotes optional). The path is never committed — it's
supplied per-invocation:
STRUCT_LIVE_ENV_FILE=/path/to/your/.env pnpm test:live
Only 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 on STRUCT_INGEST_URL
STRUCT_INGEST_KEY (on top of OPENAI_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 and struct-sdk-python
and 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 (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: Ownership of the
chat span is fixed (manual >
framework > provider — see AGENTS.md §5): when you call
ChatAnthropic.invoke()/.stream(), the LangChain integration always owns
the chat span (handleChatModelStart in langchain-callback.ts). It runs
the underlying provider call inside an internal scope
(suppressGenAi: true) that the direct @anthropic-ai/sdk patch checks via
isGenAiSuppressed() — 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/sdk
module 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 (check struct.initialized and
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 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