@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
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, custom graphs. thread_id → gen_ai.conversation.id |
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();
});
}
);
Nested agents set struct.agent.parent_session_id on the inner span, linking
subagents back to the parent.
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.
Parity with the Python SDK
Span names, attribute keys, and log event shapes match struct-sdk-python
byte-for-byte. Running the same agent logic in both SDKs against the same
tenant produces structurally identical traces in the Struct UI.
One deliberate divergence: the TS SDK finalizes streaming chat spans via the
finalMessage event, closing a known gap in the Python SDK where
messages.stream() didn't populate the pending-tool-call queue.
Development
pnpm install
pnpm -F @struct-ai/sdk typecheck
pnpm -F @struct-ai/sdk test
pnpm -F @struct-ai/sdk build
License
Apache-2.0