npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@struct-ai/sdk

v0.4.3

Published

Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry

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/langgraph

Requires 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 onlyresponses.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_callstruct.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:

  1. 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.
  2. 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:

// 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.namechat, 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:

| 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). Excludes test/live/**.

  • pnpm typechecktsc --noEmit.

  • pnpm buildtshy, 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