Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
Project description
struct-sdk
OpenTelemetry instrumentation for AI agents in Python. Captures spans, token usage, and message events from the Anthropic SDK, the Claude Agent SDK, and LangChain / LangGraph, and exports them to struct.ai for observability.
A TypeScript version is available as
@struct-ai/sdk. Both
SDKs produce structurally identical traces, so a single agent system can
mix languages without a fragmented view.
Install
pip install struct-sdk
# optional — the SDK auto-instruments these if present
pip install anthropic
pip install claude-agent-sdk
pip install langchain-core langgraph
Requires Python 3.10+.
Quickstart
Get an ingest key from
app.struct.ai/settings?tab=ingest-keys,
then call struct.init() once at startup and wrap your agent loop:
import os
from struct_sdk import struct
struct.init(
ingest_key=os.environ["STRUCT_INGEST_KEY"], # or pass the string directly
service_name="my-agent",
environment="production",
)
import anthropic
client = anthropic.AsyncAnthropic()
async with struct.agent(name="checkout"):
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
async with struct.tool(name="search"):
result = await search(msg)
What gets traced
| Library | Span type | Notes |
|---|---|---|
anthropic |
chat {model} |
Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
claude_agent_sdk |
agent, chat, execute_tool |
Telemetry comes from Claude Code itself in the subprocess; ingest credentials are propagated to it via ClaudeAgentOptions. Subagents inherit the configuration. |
langchain_core BaseChatModel |
chat {model} |
Skipped when an underlying provider SDK is also instrumented (e.g. ChatAnthropic + anthropic → a single span). |
langchain_core BaseTool |
execute_tool {name} |
tool_call_id extracted from the LangChain ToolCall when present. |
langchain_core BaseRetriever |
retrieval {name} |
|
langgraph Pregel |
invoke_agent {name} |
Covers create_react_agent, langchain.agents.create_agent, 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(session_id=conv_id) — that's the struct-native replacement for ls.tracing_context(parent=run_tree). |
Framework integration
struct.init() takes the same parameters regardless of which framework
you're instrumenting. Required: ingest_key (get one at
app.struct.ai/settings?tab=ingest-keys).
Recommended: service_name, 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.
Agent frameworks — fully auto-instrumented
For these, calling struct.init() is the only setup. Agent, tool, chat,
and retrieval spans all emit automatically.
Claude Agent SDK
from struct_sdk import struct
struct.init(ingest_key="pk-...", service_name="claude-agent")
from claude_agent_sdk import ClaudeAgentOptions, query
# Telemetry is generated by Claude Code itself in the subprocess and
# exported directly via OTLP. struct.init() configures the ingest
# credentials on ClaudeAgentOptions; subagents inherit them automatically.
LangChain / LangGraph (with an agent or graph)
from struct_sdk import struct
struct.init(ingest_key="pk-...", service_name="my-graph")
from langgraph.prebuilt import create_react_agent
# Pregel / CompiledStateGraph / AgentExecutor invocations get invoke_agent
# spans. BaseChatModel calls get chat spans. BaseTool.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(session_id=conversation_id). 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
contextvar — you don't have to ensure each
compiled_graph.ainvokegetsthread_idon its config. - One trace per request.
struct.agentcreates 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
with ls.tracing_context(parent=run_tree):
await orchestrator.ainvoke(inputs, config=config)
# After — struct-native, threads correctly, no langsmith dep
async with struct.agent(session_id=conversation_id):
await orchestrator.ainvoke(inputs, config=config)
Also works as a sync context manager (with struct.agent(...)) for
non-async handlers.
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)
from struct_sdk import struct
struct.init(ingest_key="pk-...", service_name="checkout-agent")
import anthropic
client = anthropic.AsyncAnthropic()
# Required: wrap the agent loop yourself.
async with struct.agent(name="checkout"):
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.
async with struct.tool(name="search"):
result = await search(...)
anthropic.Anthropic, anthropic.AsyncAnthropic, and the bedrock/vertex
clients 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 an AgentExecutor or LangGraph, only the chat
span emits automatically. Same rule as raw Anthropic — wrap your agent
loop in struct.agent() and tool execution in struct.tool().
from struct_sdk import struct
struct.init(ingest_key="pk-...", service_name="my-agent")
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-3-5-sonnet-20241022")
async with struct.agent(name="my-agent"):
response = await llm.ainvoke([("user", "...")])
async with struct.tool(name="search"):
...
When you do use ChatAnthropic and have the anthropic SDK installed,
the chat span comes from the Anthropic instrumentation (single span);
the LangChain layer skips its own to avoid duplicates.
Content capture
The SDK supports four capture modes controlling how prompt/response content is emitted.
from struct_sdk import struct, ContentCaptureMode
struct.init(
ingest_key=...,
content_capture=ContentCaptureMode.EVENT_ONLY, # default
# or ContentCaptureMode.NONE, SPAN_ONLY, SPAN_AND_EVENT
)
EVENT_ONLY(default): per-message content lands on OTel log records (gen_ai.{user,assistant,system,tool}.message,gen_ai.choice). Spans carry metadata only.SPAN_ONLY: content on span attributes (gen_ai.input.messages,gen_ai.output.messages).SPAN_AND_EVENT: both.NONE: no content captured. Token counts, tool call IDs, finish reasons, and other metadata still flow.
capture_content=False is a shorthand for ContentCaptureMode.NONE.
Manual scopes
struct.agent() and struct.tool() create invoke_agent and
execute_tool spans. Use them when you call an LLM SDK directly; you
don't need them when an agent framework (LangGraph, AgentExecutor,
Claude Agent SDK) is already creating those spans for you.
async with struct.agent(
name="onboarding",
session_id=conversation_id,
metadata={"tenant": "acme"},
):
async with struct.tool(name="fetch-profile"):
return await fetch_profile()
Decorator form:
@struct.agent(name="checkout")
async def run_checkout(order_id: str):
@struct.tool(name="charge-card")
async def charge():
return await stripe.charge(order_id)
return await charge()
Sub-agents (e.g. a create_agent 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 —
additionally requires that the nested invoke shares the outer agent's
trace. This works automatically when the outer tool is built with the
@tool decorator (or any callback-aware wrapping) since the nested
invoke inherits the parent's run state. Bare Tool(...) constructors
that bypass the callback chain can break the forward link; the
backlink still renders.
Semantic conventions
Emits attributes per the OTel GenAI semantic conventions:
gen_ai.operation.name—chat,execute_tool,invoke_agent,retrievalgen_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.idgen_ai.tool.{name, call.id, call.arguments, call.result}error.type+StatusCode.ERRORon failures
Note: gen_ai.usage.input_tokens for Anthropic is the true total — the
SDK adds back cache_read_input_tokens and
cache_creation_input_tokens, which Anthropic's raw response excludes
from input_tokens.
Configuration
struct.init(
ingest_key="pk-...", # required
service_name="my-agent", # default: "default-agent"
service_version="1.2.3", # default: "0.0.0"
environment="production", # default: "development"
endpoint="https://ingest.struct.ai", # default; override for self-hosted
shutdown_timeout_seconds=5.0, # default: 5.0
content_capture=ContentCaptureMode.EVENT_ONLY,
)
The SDK uses an isolated TracerProvider and LoggerProvider — your
existing OTel setup is unaffected.
Reliability
The SDK is designed never to break your application. Instrumentation
hooks, span exports, and shutdown all swallow exceptions internally; the
first failure at each site logs at WARN, subsequent ones at DEBUG.
Process exit is bounded by shutdown_timeout_seconds and runs in a
background thread, so a slow or unreachable ingest endpoint cannot hang
shutdown.
Troubleshooting
- Spans missing after instrumenting: Call
struct.init()before importing the instrumented libraries, so the SDK can wire up instrumentation before any instance is constructed. - No log records appearing: Log records only emit when the capture
mode is
EVENT_ONLYorSPAN_AND_EVENT(the default). If you setcapture_content=False, content events are disabled. - Duplicate chat spans: When an LLM provider SDK and a wrapping
framework are both instrumented (e.g.
ChatAnthropiccalling through toanthropic), the framework-level chat span is skipped to avoid duplicates.
License
Apache-2.0
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file struct_sdk-0.2.2.tar.gz.
File metadata
- Download URL: struct_sdk-0.2.2.tar.gz
- Upload date:
- Size: 41.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5dccc62370cbf7e632913e81707bdd0d5f3d310420154e40a019a3874a720d78
|
|
| MD5 |
c18c17db3d13173b6955ec7681edb099
|
|
| BLAKE2b-256 |
9dc1f736ffe664473559fc1de997d51947369cb74261470fba9975ccd06d693e
|
Provenance
The following attestation bundles were made for struct_sdk-0.2.2.tar.gz:
Publisher:
deploy-production.yml on village-chat/edgedive-monorepo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
struct_sdk-0.2.2.tar.gz -
Subject digest:
5dccc62370cbf7e632913e81707bdd0d5f3d310420154e40a019a3874a720d78 - Sigstore transparency entry: 1587989874
- Sigstore integration time:
-
Permalink:
village-chat/edgedive-monorepo@2596f0a6b283d70b621cd8c4f0c6e1186bc65d27 -
Branch / Tag:
refs/tags/v2026-05-20.1 - Owner: https://github.com/village-chat
-
Access:
internal
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy-production.yml@2596f0a6b283d70b621cd8c4f0c6e1186bc65d27 -
Trigger Event:
push
-
Statement type:
File details
Details for the file struct_sdk-0.2.2-py3-none-any.whl.
File metadata
- Download URL: struct_sdk-0.2.2-py3-none-any.whl
- Upload date:
- Size: 43.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c5371d4e5172caaa35a57cb8c22eaf8ec0f59231b6ee4edb5b56c9cfdaa4bcb
|
|
| MD5 |
8bb0cf994e5d246fda95bd0e1299cd70
|
|
| BLAKE2b-256 |
b8691dbfc2b5d48dc5848d3bb43884b97e08419a293c78d1b6ada82738951215
|
Provenance
The following attestation bundles were made for struct_sdk-0.2.2-py3-none-any.whl:
Publisher:
deploy-production.yml on village-chat/edgedive-monorepo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
struct_sdk-0.2.2-py3-none-any.whl -
Subject digest:
6c5371d4e5172caaa35a57cb8c22eaf8ec0f59231b6ee4edb5b56c9cfdaa4bcb - Sigstore transparency entry: 1587990660
- Sigstore integration time:
-
Permalink:
village-chat/edgedive-monorepo@2596f0a6b283d70b621cd8c4f0c6e1186bc65d27 -
Branch / Tag:
refs/tags/v2026-05-20.1 - Owner: https://github.com/village-chat
-
Access:
internal
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy-production.yml@2596f0a6b283d70b621cd8c4f0c6e1186bc65d27 -
Trigger Event:
push
-
Statement type: