Metadata-Version: 2.4
Name: struct-sdk
Version: 0.1.0
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,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'
Provides-Extra: demo
Requires-Dist: langchain-anthropic>=0.3.0; extra == 'demo'
Requires-Dist: langchain-core>=0.3.0; extra == 'demo'
Requires-Dist: langchain-openai>=0.2.0; 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>=0.2.0; extra == 'langchain'
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 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 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()

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` and custom graphs. `thread_id` → `gen_ai.conversation.id`. |

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

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

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

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

Nested agents are linked to their parent via the
`struct.agent.parent_session_id` attribute on the inner span.

## Semantic conventions

Emits attributes per the OTel GenAI semantic conventions:

- `gen_ai.operation.name` — `chat`, `execute_tool`, `invoke_agent`, `retrieval`
- `gen_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.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

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