
Security News
OpenCode User Hit with Hostile Backlash After Reporting Free AI Loophole
An OpenCode user faced harassment after reporting a free AI usage loophole, drawing a bitter backlash in a GitHub thread that has since been deleted.
@struct-ai/sdk
Advanced tools
Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
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.
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+.
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);
});
});
| 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). |
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.
For these, calling struct.init() is the only setup. Agent, tool, chat,
and retrieval spans all emit automatically.
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.
struct.agentFor 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:
compiledGraph.invoke
gets thread_id on its config.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);
});
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.
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.
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.
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.
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).
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.
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_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.idgen_ai.tool.{name, call.id, call.arguments, call.result}error.type + StatusCode.ERROR on failureserror.type values emittedThe 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.
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.
@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.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.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.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.Apache-2.0
FAQs
Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
The npm package @struct-ai/sdk receives a total of 509 weekly downloads. As such, @struct-ai/sdk popularity was classified as not popular.
We found that @struct-ai/sdk demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
An OpenCode user faced harassment after reporting a free AI usage loophole, drawing a bitter backlash in a GitHub thread that has since been deleted.

Threat Research
/Security News
319 browser extensions installed by 13.4 million users hijack searches, capture queries, and collect referral payments from Yahoo and Bing.

Threat Research
Malicious Firefox extensions mimic Phantom, Jupiter, and Bitget wallets to steal recovery phrases and private keys, with one fetching its payload remotely.