Skip to main content

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()

# Decorate each tool — auto-captures arguments + result + tool_call_id.
@struct.tool()
async def search(query: str):
    ...

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"}],
    )
    result = await search(query="...")

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:

  1. 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.ainvoke 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
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()

# Recommended: define each tool as a function and DECORATE it. The decorator
# auto-captures the tool's arguments + result on the execute_tool span and
# auto-fills tool_call_id from the preceding Anthropic response — no manual
# bookkeeping.
@struct.tool()
async def search(query: str):
    ...

# 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=[...],
    )
    # Dispatching a decorated tool inside the agent emits a fully-populated
    # execute_tool span (name, id, arguments, result):
    result = await search(query="...")

For dynamic dispatch (the LLM picks a tool from a registry at runtime), apply the decorator at runtime — still automatic, just bind the name when you wrap the callable:

registry = {t.name: struct.tool(name=t.name)(t.execute) for t in tools}
result = await registry[block.name](**block.input)   # arguments + result captured

struct.tool() can also be used as a context manager (async with struct.tool(name=...): ...) to instrument an arbitrary block of code as a tool span. That form is a manual escape hatch — it does NOT auto-capture arguments/result (a with block can't see the body's return value), so prefer the decorator for actual tool calls. See Parallel tool calls for the one runtime value (tool_call_id) you must supply under concurrency.

anthropic.Anthropic, anthropic.AsyncAnthropic, and the bedrock/vertex clients are all auto-instrumented for chat spans.

Parallel tool calls — pass tool_call_id explicitly

When you execute an assistant turn's tool calls sequentially — one await at a time, in the order the tool_use blocks appear — struct.tool() auto-fills gen_ai.tool.call.id by matching each span to the next pending tool_use of the same tool name. Nothing extra to do.

When you execute them concurrently (e.g. asyncio.gather), that name-and-order matching is ambiguous: two struct.tool(name="search") spans can start in any order, so the auto-fill may attach the wrong id (and thus the wrong arguments/result) to a call. In that case pass tool_call_id explicitly from the originating tool_use block — an explicit id always overrides the auto-linkage:

async def run_one(block):
    # The id from THIS block overrides the name/order auto-fill.
    async with struct.tool(name=block.name, tool_call_id=block.id):
        return await dispatch(block.name, **block.input)

# Concurrent execution — each tool span still carries the correct id.
results = await asyncio.gather(*[run_one(b) for b in tool_use_blocks])

Rule of thumb: serial tool execution → automatic; concurrent tool execution → provide tool_call_id= yourself. (Auto-instrumented frameworks such as LangChain read the id from the framework's ToolCall, so this only applies when you drive the tool loop directly against an LLM SDK.)

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.namechat, execute_tool, invoke_agent, retrieval
  • gen_ai.provider.nameanthropic, 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 — 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_ONLY or SPAN_AND_EVENT (the default). If you set capture_content=False, content events are disabled.
  • Duplicate chat spans: When an LLM provider SDK and a wrapping framework are both instrumented (e.g. ChatAnthropic calling through to anthropic), the framework-level chat span is skipped to avoid duplicates.

License

Apache-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

struct_sdk-0.2.8.tar.gz (45.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

struct_sdk-0.2.8-py3-none-any.whl (47.6 kB view details)

Uploaded Python 3

File details

Details for the file struct_sdk-0.2.8.tar.gz.

File metadata

  • Download URL: struct_sdk-0.2.8.tar.gz
  • Upload date:
  • Size: 45.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for struct_sdk-0.2.8.tar.gz
Algorithm Hash digest
SHA256 9801b082340d2fc07c00a80df805fe7d169005daf018320474aa3368d6b68d83
MD5 0d63eced8932122317d1e90f2e97632e
BLAKE2b-256 5754c5302565c80a7db57b57f93aaff0986920239df193ed80a84d3cb5d8c634

See more details on using hashes here.

Provenance

The following attestation bundles were made for struct_sdk-0.2.8.tar.gz:

Publisher: deploy-production.yml on village-chat/edgedive-monorepo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file struct_sdk-0.2.8-py3-none-any.whl.

File metadata

  • Download URL: struct_sdk-0.2.8-py3-none-any.whl
  • Upload date:
  • Size: 47.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for struct_sdk-0.2.8-py3-none-any.whl
Algorithm Hash digest
SHA256 62623a5b1e9ca83cda498a32afd21448e2f8eb335c6bad766f77628481d78d8d
MD5 7d25f1f214e708b2bfcebedb2d19b6ab
BLAKE2b-256 48863c16111a28d292bcea936a35636ea58a4097e0f2f35a4a23f54770db7976

See more details on using hashes here.

Provenance

The following attestation bundles were made for struct_sdk-0.2.8-py3-none-any.whl:

Publisher: deploy-production.yml on village-chat/edgedive-monorepo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page