Metadata-Version: 2.4
Name: struct-sdk
Version: 0.2.24
Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
Project-URL: Homepage, https://struct.ai
Project-URL: Documentation, https://struct.ai/docs
Project-URL: Issues, https://struct.ai/support
Author-email: Struct <support@struct.ai>
License: Apache-2.0
License-File: LICENSE
Keywords: agent,ai,anthropic,langchain,observability,openai,opentelemetry,struct,tracing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.10
Requires-Dist: opentelemetry-api>=1.27.0
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0
Requires-Dist: opentelemetry-sdk>=1.27.0
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.30.0; extra == 'anthropic'
Provides-Extra: claude-agent-sdk
Requires-Dist: claude-agent-sdk>=0.1.59; extra == 'claude-agent-sdk'
Requires-Dist: mcp>=1.28.1; extra == 'claude-agent-sdk'
Provides-Extra: demo
Requires-Dist: langchain-anthropic>=1.4.6; extra == 'demo'
Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
Requires-Dist: langchain-openai>=0.2.0; extra == 'demo'
Requires-Dist: langchain>=1.3.9; extra == 'demo'
Requires-Dist: langgraph>=0.2.0; extra == 'demo'
Requires-Dist: python-dotenv>=1.0.0; extra == 'demo'
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
Requires-Dist: pytest>=9.0.3; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: langchain
Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
Provides-Extra: openai
Requires-Dist: openai>=1.66.0; extra == 'openai'
Description-Content-Type: text/markdown

# struct-sdk

OpenTelemetry instrumentation for AI agents in Python. Captures spans,
token usage, and message events from the Anthropic SDK, the OpenAI SDK
(Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
exports them to [struct.ai](https://struct.ai) for observability.

A TypeScript version is available as
[`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
SDKs produce structurally identical traces, so a single agent system can
mix languages without a fragmented view.

## Install

```bash
pip install struct-sdk
# optional — the SDK auto-instruments these if present
pip install anthropic
pip install openai
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](https://app.struct.ai/settings?tab=ingest-keys),
then call `struct.init()` once at startup and wrap your agent loop:

```python
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. |
| `openai` (Responses API) | `chat {model}` | Non-streaming `responses.create` on sync and async clients (Azure clients included). Cached-token accounting from `usage.input_tokens_details`. Streaming and `chat.completions` calls pass through untraced. Message-content capture at parity with the Anthropic integration: per-message events (`gen_ai.system/user/assistant/tool.message`) and `gen_ai.choice`, plus `gen_ai.input.messages` / `gen_ai.output.messages` in span-content mode. Reasoning items are skipped (`encrypted_content` is never emitted). |
| `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)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — 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](https://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

```python
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)

```python
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:

```python
# 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)

```python
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:

```python
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](#parallel-tool-calls--pass-tool_call_id-explicitly) 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:

```python
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()`.

```python
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.

```python
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.

```python
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:

```python
@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`, `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. `RuntimeError`, `httpx.ConnectError`) | the instrumented call raised | 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.

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 — the
SDK adds back `cache_read_input_tokens` and
`cache_creation_input_tokens`, which Anthropic's raw response excludes
from `input_tokens`.

## Configuration

```python
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
