struct-sdk 0.2.33__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,703 @@
1
+ Metadata-Version: 2.5
2
+ Name: struct-sdk
3
+ Version: 0.3.2
4
+ Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
5
+ Project-URL: Homepage, https://struct.ai
6
+ Project-URL: Documentation, https://struct.ai/docs
7
+ Project-URL: Issues, https://struct.ai/support
8
+ Author-email: Struct <support@struct.ai>
9
+ License: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: agent,ai,anthropic,langchain,observability,openai,opentelemetry,struct,tracing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Monitoring
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: opentelemetry-api>=1.45.1
25
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0
26
+ Requires-Dist: opentelemetry-sdk>=1.27.0
27
+ Provides-Extra: anthropic
28
+ Requires-Dist: anthropic>=0.30.0; extra == 'anthropic'
29
+ Provides-Extra: auto
30
+ Requires-Dist: opentelemetry-instrumentation-aio-pika; extra == 'auto'
31
+ Requires-Dist: opentelemetry-instrumentation-aiohttp-client; extra == 'auto'
32
+ Requires-Dist: opentelemetry-instrumentation-aiohttp-server; extra == 'auto'
33
+ Requires-Dist: opentelemetry-instrumentation-aiokafka; extra == 'auto'
34
+ Requires-Dist: opentelemetry-instrumentation-aiopg; extra == 'auto'
35
+ Requires-Dist: opentelemetry-instrumentation-asyncpg; extra == 'auto'
36
+ Requires-Dist: opentelemetry-instrumentation-botocore; extra == 'auto'
37
+ Requires-Dist: opentelemetry-instrumentation-cassandra; extra == 'auto'
38
+ Requires-Dist: opentelemetry-instrumentation-celery; extra == 'auto'
39
+ Requires-Dist: opentelemetry-instrumentation-confluent-kafka; extra == 'auto'
40
+ Requires-Dist: opentelemetry-instrumentation-django; extra == 'auto'
41
+ Requires-Dist: opentelemetry-instrumentation-falcon; extra == 'auto'
42
+ Requires-Dist: opentelemetry-instrumentation-fastapi; extra == 'auto'
43
+ Requires-Dist: opentelemetry-instrumentation-flask; extra == 'auto'
44
+ Requires-Dist: opentelemetry-instrumentation-grpc; extra == 'auto'
45
+ Requires-Dist: opentelemetry-instrumentation-httpx; extra == 'auto'
46
+ Requires-Dist: opentelemetry-instrumentation-jinja2; extra == 'auto'
47
+ Requires-Dist: opentelemetry-instrumentation-kafka-python; extra == 'auto'
48
+ Requires-Dist: opentelemetry-instrumentation-logging; extra == 'auto'
49
+ Requires-Dist: opentelemetry-instrumentation-mysql; extra == 'auto'
50
+ Requires-Dist: opentelemetry-instrumentation-mysqlclient; extra == 'auto'
51
+ Requires-Dist: opentelemetry-instrumentation-pika; extra == 'auto'
52
+ Requires-Dist: opentelemetry-instrumentation-psycopg; extra == 'auto'
53
+ Requires-Dist: opentelemetry-instrumentation-psycopg2; extra == 'auto'
54
+ Requires-Dist: opentelemetry-instrumentation-pymemcache; extra == 'auto'
55
+ Requires-Dist: opentelemetry-instrumentation-pymongo; extra == 'auto'
56
+ Requires-Dist: opentelemetry-instrumentation-pymssql; extra == 'auto'
57
+ Requires-Dist: opentelemetry-instrumentation-pymysql; extra == 'auto'
58
+ Requires-Dist: opentelemetry-instrumentation-pyramid; extra == 'auto'
59
+ Requires-Dist: opentelemetry-instrumentation-redis; extra == 'auto'
60
+ Requires-Dist: opentelemetry-instrumentation-requests; extra == 'auto'
61
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy; extra == 'auto'
62
+ Requires-Dist: opentelemetry-instrumentation-sqlite3; extra == 'auto'
63
+ Requires-Dist: opentelemetry-instrumentation-starlette; extra == 'auto'
64
+ Requires-Dist: opentelemetry-instrumentation-system-metrics; extra == 'auto'
65
+ Requires-Dist: opentelemetry-instrumentation-threading; extra == 'auto'
66
+ Requires-Dist: opentelemetry-instrumentation-tornado; extra == 'auto'
67
+ Requires-Dist: opentelemetry-instrumentation-tortoiseorm; extra == 'auto'
68
+ Requires-Dist: opentelemetry-instrumentation-urllib; extra == 'auto'
69
+ Requires-Dist: opentelemetry-instrumentation-urllib3; extra == 'auto'
70
+ Requires-Dist: opentelemetry-instrumentation>=0.51b0; extra == 'auto'
71
+ Requires-Dist: opentelemetry-resource-detector-containerid; extra == 'auto'
72
+ Provides-Extra: azure
73
+ Requires-Dist: azure-core-tracing-opentelemetry>=1.0.0b11; extra == 'azure'
74
+ Provides-Extra: claude-agent-sdk
75
+ Requires-Dist: claude-agent-sdk>=0.2.121; extra == 'claude-agent-sdk'
76
+ Requires-Dist: mcp<2,>=1.30.0; extra == 'claude-agent-sdk'
77
+ Provides-Extra: demo
78
+ Requires-Dist: langchain-anthropic>=1.4.6; extra == 'demo'
79
+ Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
80
+ Requires-Dist: langchain-openai>=0.2.0; extra == 'demo'
81
+ Requires-Dist: langchain>=1.3.9; extra == 'demo'
82
+ Requires-Dist: langgraph>=0.2.0; extra == 'demo'
83
+ Requires-Dist: python-dotenv>=1.0.0; extra == 'demo'
84
+ Provides-Extra: dev
85
+ Requires-Dist: mypy>=1.10; extra == 'dev'
86
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
87
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
88
+ Requires-Dist: ruff>=0.5; extra == 'dev'
89
+ Provides-Extra: exceptions
90
+ Requires-Dist: opentelemetry-instrumentation-exceptions; extra == 'exceptions'
91
+ Provides-Extra: langchain
92
+ Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
93
+ Provides-Extra: openai
94
+ Requires-Dist: openai>=1.66.0; extra == 'openai'
95
+ Provides-Extra: pydantic-ai
96
+ Requires-Dist: pydantic-ai-slim>=2.53.0; extra == 'pydantic-ai'
97
+ Provides-Extra: structlog
98
+ Requires-Dist: opentelemetry-instrumentation-structlog; extra == 'structlog'
99
+ Provides-Extra: temporal
100
+ Requires-Dist: temporalio>=1.11.0; extra == 'temporal'
101
+ Description-Content-Type: text/markdown
102
+
103
+ # struct-sdk
104
+
105
+ OpenTelemetry instrumentation for AI agents in Python. Captures spans,
106
+ token usage, and message events from the Anthropic SDK, the OpenAI SDK
107
+ (Responses API), pydantic-ai, the Claude Agent SDK, and LangChain /
108
+ LangGraph, and exports them to [struct.ai](https://struct.ai) for observability.
109
+
110
+ A TypeScript version is available as
111
+ [`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
112
+ SDKs produce structurally identical traces, so a single agent system can
113
+ mix languages without a fragmented view.
114
+
115
+ ## Install
116
+
117
+ ```bash
118
+ pip install struct-sdk
119
+ # optional — the SDK auto-instruments these if present
120
+ pip install anthropic
121
+ pip install openai
122
+ pip install pydantic-ai-slim
123
+ pip install claude-agent-sdk
124
+ pip install langchain-core langgraph
125
+ ```
126
+
127
+ Requires Python 3.10+.
128
+
129
+ ## Quickstart
130
+
131
+ Get an ingest key from
132
+ [app.struct.ai/settings?tab=ingest-keys](https://app.struct.ai/settings?tab=ingest-keys),
133
+ then call `struct.init()` once at startup and wrap your agent loop:
134
+
135
+ ```python
136
+ import os
137
+ from struct_sdk import struct
138
+
139
+ struct.init(
140
+ ingest_key=os.environ["STRUCT_INGEST_KEY"], # or pass the string directly
141
+ service_name="my-agent",
142
+ environment="production",
143
+ )
144
+
145
+ import anthropic
146
+ client = anthropic.AsyncAnthropic()
147
+
148
+ # Decorate each tool — auto-captures arguments + result + tool_call_id.
149
+ @struct.tool()
150
+ async def search(query: str):
151
+ ...
152
+
153
+ async with struct.agent(name="checkout"):
154
+ msg = await client.messages.create(
155
+ model="claude-3-5-sonnet-20241022",
156
+ max_tokens=1024,
157
+ messages=[{"role": "user", "content": "plan my checkout flow"}],
158
+ )
159
+ result = await search(query="...")
160
+ ```
161
+
162
+ ## What gets traced
163
+
164
+ | Library | Span type | Notes |
165
+ |---|---|---|
166
+ | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
167
+ | `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). |
168
+ | `pydantic_ai` | `invoke_agent {agent}`, `chat {model}`, `execute_tool {tool}` | pydantic-ai's own instrumentation, turned on with Struct's tracer provider. See [pydantic-ai](#pydantic-ai). |
169
+ | `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. |
170
+ | `langchain_core` `BaseChatModel` | `chat {model}` | When the underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic`), the provider's span is suppressed, so there is a single span. |
171
+ | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
172
+ | `langchain_core` `BaseRetriever` | `retrieval {name}` | |
173
+ | `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)`. |
174
+
175
+ ## Framework integration
176
+
177
+ `struct.init()` takes the same parameters regardless of which framework
178
+ you're instrumenting. Required: `ingest_key` (get one at
179
+ [app.struct.ai/settings?tab=ingest-keys](https://app.struct.ai/settings?tab=ingest-keys)).
180
+ Recommended: `service_name`, `environment`.
181
+
182
+ What you need to do beyond `init()` depends on whether you're using an
183
+ **agent framework** (which has built-in concepts of agents and tools) or
184
+ an **LLM SDK directly** (which only knows about chat completions). The
185
+ SDK auto-instruments both, but only agent frameworks get full agent +
186
+ tool spans for free — when you call an LLM SDK directly, you have to
187
+ tell the SDK where the agent and tool boundaries are.
188
+
189
+ Call `init()` once, as early as possible, *before* the instrumented
190
+ libraries are imported.
191
+
192
+ ### Agent frameworks — fully auto-instrumented
193
+
194
+ For these, calling `struct.init()` is the only setup. Agent, tool, chat,
195
+ and retrieval spans all emit automatically.
196
+
197
+ #### Claude Agent SDK
198
+
199
+ ```python
200
+ from struct_sdk import struct
201
+ struct.init(ingest_key="pk-...", service_name="claude-agent")
202
+
203
+ from claude_agent_sdk import ClaudeAgentOptions, query
204
+ # Telemetry is generated by Claude Code itself in the subprocess and
205
+ # exported directly via OTLP. struct.init() configures the ingest
206
+ # credentials on ClaudeAgentOptions; subagents inherit them automatically.
207
+ ```
208
+
209
+ #### pydantic-ai
210
+
211
+ ```python
212
+ from struct_sdk import struct
213
+ struct.init(ingest_key="pk-...", service_name="support-agent")
214
+
215
+ from pydantic_ai import Agent
216
+ agent = Agent("openai:gpt-5.5", name="support")
217
+ result = await agent.run("Refund order 42", conversation_id="thread-42")
218
+ ```
219
+
220
+ pydantic-ai already emits OpenTelemetry GenAI spans; `struct.init()` turns
221
+ that on for every agent (`Agent.instrument_all`) with Struct's tracer
222
+ provider, unless you already set your own default (for example with
223
+ logfire). You get pydantic-ai's `invoke_agent`, `chat` and `execute_tool`
224
+ spans, with one `chat` span per model call (Struct's OpenAI and Anthropic
225
+ patches stay out of the way for pydantic-ai's own provider calls; model
226
+ calls your code makes while reading a stream keep their spans). During agent
227
+ runs, Struct guards telemetry faults and caps content. Realtime sessions and
228
+ direct `InstrumentedModel` exception recording are outside this guarantee.
229
+
230
+ - **Content** goes on span attributes only (pydantic-ai has no message
231
+ events): `span_only` / `span_and_event` include prompts, answers and tool
232
+ arguments; `event_only` and `none` do not. Tool definitions are always
233
+ recorded.
234
+ - **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
235
+ around the run wins; otherwise pydantic-ai's `conversation_id` is used.
236
+ Instrumentation configured on your own provider keeps its conversation IDs.
237
+ - **Turn it off** with
238
+ `struct.init(..., instrumentation_options={"pydantic_ai": {"enabled": False}})`,
239
+ or for one agent with `agent.instrument = False`. An
240
+ `Agent.instrument_all(False)` before `struct.init()` is pydantic-ai's
241
+ initial value, so it does not keep pydantic-ai off.
242
+
243
+ #### LangChain / LangGraph (with an agent or graph)
244
+
245
+ ```python
246
+ from struct_sdk import struct
247
+ struct.init(ingest_key="pk-...", service_name="my-graph")
248
+
249
+ from langgraph.prebuilt import create_react_agent
250
+ # Pregel / CompiledStateGraph / AgentExecutor invocations get invoke_agent
251
+ # spans. BaseChatModel calls get chat spans. BaseTool.invoke gets
252
+ # execute_tool spans. BaseRetriever.invoke gets retrieval spans.
253
+ ```
254
+
255
+ ##### Recommended pattern: wrap LangChain entry points in `struct.agent`
256
+
257
+ For multi-turn HTTP-style usage (every request continues the same
258
+ conversation), wrap your request handler in
259
+ `struct.agent(session_id=conversation_id)`. This is the struct-native
260
+ replacement for `with ls.tracing_context(parent=run_tree):` and gives
261
+ you two things you can't get from `configurable.thread_id` alone:
262
+
263
+ 1. **Threading without per-call config plumbing.** Every nested
264
+ LangChain call inherits the conversation id via the SDK's ambient
265
+ contextvar — you don't have to ensure each `compiled_graph.ainvoke`
266
+ gets `thread_id` on its config.
267
+ 2. **One trace per request.** `struct.agent` creates a parent OTel span
268
+ so all the LangChain work for the request nests under one trace
269
+ (clean tree, "Subagents" / "Spawned by" UI links work). Without it,
270
+ each `.invoke()` becomes its own root trace, and the UI's session
271
+ list shows a non-deterministic agent name (`omni_agent`,
272
+ `LangGraph`, the first sub-agent it sees…).
273
+
274
+ Migrating from LangSmith:
275
+
276
+ ```python
277
+ # Before — LangSmith convention, fragments under struct-sdk
278
+ with ls.tracing_context(parent=run_tree):
279
+ await orchestrator.ainvoke(inputs, config=config)
280
+
281
+ # After — struct-native, threads correctly, no langsmith dep
282
+ async with struct.agent(session_id=conversation_id):
283
+ await orchestrator.ainvoke(inputs, config=config)
284
+ ```
285
+
286
+ Also works as a sync context manager (`with struct.agent(...)`) for
287
+ non-async handlers.
288
+
289
+ ### LLM SDKs used directly — manual agent + tool scopes required
290
+
291
+ When you call an LLM SDK directly (no agent framework wrapping it), only
292
+ `chat` spans emit automatically. You need to wrap your agent loop in
293
+ `struct.agent()` and each tool execution in `struct.tool()` so the SDK
294
+ knows where to put the agent and tool boundaries — otherwise you'll see
295
+ free-floating chat spans with no agent or tool context around them.
296
+
297
+ #### Anthropic SDK (raw)
298
+
299
+ ```python
300
+ from struct_sdk import struct
301
+ struct.init(ingest_key="pk-...", service_name="checkout-agent")
302
+
303
+ import anthropic
304
+ client = anthropic.AsyncAnthropic()
305
+
306
+ # Recommended: define each tool as a function and DECORATE it. The decorator
307
+ # auto-captures the tool's arguments + result on the execute_tool span and
308
+ # auto-fills tool_call_id from the preceding Anthropic response — no manual
309
+ # bookkeeping.
310
+ @struct.tool()
311
+ async def search(query: str):
312
+ ...
313
+
314
+ # Required: wrap the agent loop yourself.
315
+ async with struct.agent(name="checkout"):
316
+ msg = await client.messages.create(
317
+ model="claude-3-5-sonnet-20241022",
318
+ max_tokens=1024,
319
+ messages=[...],
320
+ )
321
+ # Dispatching a decorated tool inside the agent emits a fully-populated
322
+ # execute_tool span (name, id, arguments, result):
323
+ result = await search(query="...")
324
+ ```
325
+
326
+ For **dynamic dispatch** (the LLM picks a tool from a registry at runtime),
327
+ apply the decorator at runtime — still automatic, just bind the name when you
328
+ wrap the callable:
329
+
330
+ ```python
331
+ registry = {t.name: struct.tool(name=t.name)(t.execute) for t in tools}
332
+ result = await registry[block.name](**block.input) # arguments + result captured
333
+ ```
334
+
335
+ > `struct.tool()` can also be used as a context manager
336
+ > (`async with struct.tool(name=...): ...`) to instrument an arbitrary block of
337
+ > code as a tool span. That form is a **manual escape hatch** — it does NOT
338
+ > auto-capture arguments/result (a `with` block can't see the body's return
339
+ > value), so prefer the decorator for actual tool calls. See
340
+ > [Parallel tool calls](#parallel-tool-calls--pass-tool_call_id-explicitly) for
341
+ > the one runtime value (`tool_call_id`) you must supply under concurrency.
342
+
343
+ `anthropic.Anthropic`, `anthropic.AsyncAnthropic`, and the bedrock/vertex
344
+ clients are all auto-instrumented for chat spans.
345
+
346
+ #### Parallel tool calls — pass `tool_call_id` explicitly
347
+
348
+ When you execute an assistant turn's tool calls **sequentially** — one
349
+ `await` at a time, in the order the `tool_use` blocks appear — `struct.tool()`
350
+ auto-fills `gen_ai.tool.call.id` by matching each span to the next pending
351
+ `tool_use` of the same tool name. Nothing extra to do.
352
+
353
+ When you execute them **concurrently** (e.g. `asyncio.gather`), that
354
+ name-and-order matching is ambiguous: two `struct.tool(name="search")` spans
355
+ can start in any order, so the auto-fill may attach the wrong id (and thus the
356
+ wrong arguments/result) to a call. In that case **pass `tool_call_id`
357
+ explicitly** from the originating `tool_use` block — an explicit id always
358
+ overrides the auto-linkage:
359
+
360
+ ```python
361
+ async def run_one(block):
362
+ # The id from THIS block overrides the name/order auto-fill.
363
+ async with struct.tool(name=block.name, tool_call_id=block.id):
364
+ return await dispatch(block.name, **block.input)
365
+
366
+ # Concurrent execution — each tool span still carries the correct id.
367
+ results = await asyncio.gather(*[run_one(b) for b in tool_use_blocks])
368
+ ```
369
+
370
+ Rule of thumb: **serial tool execution → automatic; concurrent tool execution
371
+ → provide `tool_call_id=` yourself.** (Auto-instrumented frameworks such as
372
+ LangChain read the id from the framework's `ToolCall`, so this only applies
373
+ when you drive the tool loop directly against an LLM SDK.)
374
+
375
+ #### LangChain `BaseChatModel` (no agent/graph)
376
+
377
+ If you call `ChatAnthropic.invoke(...)` (or any other `BaseChatModel`)
378
+ without wrapping it in an `AgentExecutor` or LangGraph, only the chat
379
+ span emits automatically. Same rule as raw Anthropic — wrap your agent
380
+ loop in `struct.agent()` and tool execution in `struct.tool()`.
381
+
382
+ ```python
383
+ from struct_sdk import struct
384
+ struct.init(ingest_key="pk-...", service_name="my-agent")
385
+
386
+ from langchain_anthropic import ChatAnthropic
387
+ llm = ChatAnthropic(model="claude-3-5-sonnet-20241022")
388
+
389
+ async with struct.agent(name="my-agent"):
390
+ response = await llm.ainvoke([("user", "...")])
391
+ async with struct.tool(name="search"):
392
+ ...
393
+ ```
394
+
395
+ When you do use `ChatAnthropic` *and* have the `anthropic` SDK installed,
396
+ the chat span comes from the LangChain layer (single span); the Anthropic
397
+ instrumentation skips its own to avoid duplicates.
398
+
399
+ ## Content capture
400
+
401
+ The SDK supports four capture modes controlling how prompt/response
402
+ content is emitted.
403
+
404
+ ```python
405
+ from struct_sdk import struct, ContentCaptureMode
406
+
407
+ struct.init(
408
+ ingest_key=...,
409
+ content_capture=ContentCaptureMode.EVENT_ONLY, # opt in; the default is NONE
410
+ # or ContentCaptureMode.SPAN_ONLY, SPAN_AND_EVENT
411
+ )
412
+ ```
413
+
414
+ - **`EVENT_ONLY`**: per-message content lands on OTel log
415
+ records (`gen_ai.{user,assistant,system,tool}.message`,
416
+ `gen_ai.choice`). Spans carry metadata only.
417
+ - **`SPAN_ONLY`**: content on span attributes (`gen_ai.input.messages`,
418
+ `gen_ai.output.messages`).
419
+ - **`SPAN_AND_EVENT`**: both.
420
+ - **`NONE`** (default since 0.3.0): no content captured, including the
421
+ user prompt on the agent span and tool arguments and results. Token
422
+ counts, tool call IDs, finish reasons, and other metadata still flow.
423
+
424
+ `capture_content=False` is a shorthand for `ContentCaptureMode.NONE`, and
425
+ `capture_content=True` for `EVENT_ONLY`.
426
+
427
+ ## Manual scopes
428
+
429
+ `struct.agent()` and `struct.tool()` create `invoke_agent` and
430
+ `execute_tool` spans. Use them when you call an LLM SDK directly; you
431
+ don't need them when an agent framework (LangGraph, AgentExecutor,
432
+ Claude Agent SDK) is already creating those spans for you.
433
+
434
+ ```python
435
+ async with struct.agent(
436
+ name="onboarding",
437
+ session_id=conversation_id,
438
+ metadata={"tenant": "acme"},
439
+ ):
440
+ async with struct.tool(name="fetch-profile"):
441
+ return await fetch_profile()
442
+ ```
443
+
444
+ Decorator form:
445
+
446
+ ```python
447
+ @struct.agent(name="checkout")
448
+ async def run_checkout(order_id: str):
449
+ @struct.tool(name="charge-card")
450
+ async def charge():
451
+ return await stripe.charge(order_id)
452
+
453
+ return await charge()
454
+ ```
455
+
456
+ Sub-agents (e.g. a `create_agent` graph invoked from inside another
457
+ agent's tool body) record their parent via the
458
+ `struct.agent.parent_session_id` attribute on the inner `invoke_agent`
459
+ span. This powers the UI's "Spawned by" backlink, which works for any
460
+ nested invocation.
461
+
462
+ The parent's "Subagents" forward list — the inverse direction —
463
+ additionally requires that the nested invoke shares the outer agent's
464
+ trace. This works automatically when the outer tool is built with the
465
+ `@tool` decorator (or any callback-aware wrapping) since the nested
466
+ invoke inherits the parent's run state. Bare `Tool(...)` constructors
467
+ that bypass the callback chain can break the forward link; the
468
+ backlink still renders.
469
+
470
+ ## Semantic conventions
471
+
472
+ Emits attributes per the OTel GenAI semantic conventions:
473
+
474
+ - `gen_ai.operation.name` — `chat`, `execute_tool`, `invoke_agent`, `retrieval`
475
+ - `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.
476
+ - `gen_ai.request.{model, max_tokens, temperature, top_p, top_k, stop_sequences}`
477
+ - `gen_ai.response.{model, id, finish_reasons}`
478
+ - `gen_ai.usage.{input_tokens, output_tokens, cache_read.input_tokens, cache_creation.input_tokens}`
479
+ - `gen_ai.conversation.id`
480
+ - `gen_ai.tool.{name, call.id, call.arguments, call.result}`
481
+ - `error.type` + `StatusCode.ERROR` on failures
482
+
483
+ ### `error.type` values emitted
484
+
485
+ The OTel conventions ask instrumentations to document the error values
486
+ they report ("Instrumentations SHOULD document the list of errors they
487
+ report"). This SDK emits exactly two kinds of value, both alongside
488
+ span `StatusCode.ERROR`:
489
+
490
+ | Value | When | Meaning |
491
+ | --- | --- | --- |
492
+ | exception class name (e.g. `RuntimeError`, `httpx.ConnectError`) | the instrumented call raised | We failed to execute the request. Also records an OTel exception event. |
493
+ | `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. |
494
+
495
+ `tool_error` is a deliberate low-cardinality sentinel, which the
496
+ `error.type` convention explicitly permits ("another low-cardinality
497
+ error identifier"; a custom value MAY be used where no well-known one
498
+ applies). The same split is used by OpenTelemetry's MCP instrumentation
499
+ in OpenLLMetry, which likewise reports `error.type="tool_error"` for the
500
+ `isError` path and the exception class name otherwise.
501
+
502
+ The distinction is what lets a monitor page on genuine execution
503
+ failures while excluding failures the model already saw and can recover
504
+ from.
505
+
506
+ Note: `gen_ai.usage.input_tokens` for Anthropic is the true total — the
507
+ SDK adds back `cache_read_input_tokens` and
508
+ `cache_creation_input_tokens`, which Anthropic's raw response excludes
509
+ from `input_tokens`.
510
+
511
+ ## Configuration
512
+
513
+ ```python
514
+ struct.init(
515
+ ingest_key="pk-...", # required
516
+ service_name="my-agent", # default: OTEL_SERVICE_NAME, else "default-agent"
517
+ service_version="1.2.3", # default: from OTEL_RESOURCE_ATTRIBUTES, else "0.0.0"
518
+ environment="production", # default: from OTEL_RESOURCE_ATTRIBUTES, else "development"
519
+ endpoint="https://ingest.struct.ai", # default; override for self-hosted
520
+ shutdown_timeout_seconds=5.0, # default: 5.0
521
+ content_capture=ContentCaptureMode.EVENT_ONLY, # default: NONE
522
+ auto_instrument=True, # default; False = AI libraries only
523
+ instrumentation_options=None, # per-library full-app settings
524
+ redact_attributes=["*.password"], # default: STRUCT_REDACT_ATTRIBUTES
525
+ )
526
+ ```
527
+
528
+ With `auto_instrument=False` the SDK uses an isolated `TracerProvider` and
529
+ `LoggerProvider` and instruments only the AI libraries; your existing OTel
530
+ setup is unaffected. The default is [full-app mode](#full-app-mode).
531
+
532
+ Service identity follows the standard OpenTelemetry variables when you don't
533
+ pass it: `OTEL_SERVICE_NAME`, and `service.version` and
534
+ `deployment.environment` in `OTEL_RESOURCE_ATTRIBUTES`. Arguments to `init()`
535
+ win. The resource also carries host, runtime and (with the `[auto]` extra)
536
+ container metadata, plus the commit when `STRUCT_GIT_COMMIT_SHA` and
537
+ `STRUCT_GIT_REPOSITORY_URL` are set. It includes a `service.instance.id` so
538
+ replicas stay distinguishable; an ID from `OTEL_RESOURCE_ATTRIBUTES` is
539
+ preserved.
540
+
541
+ In pre-fork servers (for example `gunicorn --preload`), call `struct.init()` in
542
+ each worker (gunicorn's `post_fork` hook), not in the parent: forked workers
543
+ would otherwise share the parent's instance id. A worker that exports after
544
+ being forked from an initialized parent logs one INFO line saying so.
545
+
546
+ ### Redaction
547
+
548
+ `redact_attributes` takes glob patterns, matched case-insensitively. Span,
549
+ span-event and log attributes whose keys match leave the process as
550
+ `[REDACTED]`, for example `["http.request.header.authorization", "*.password"]`.
551
+ `STRUCT_REDACT_ATTRIBUTES` (comma-separated) sets them without code. Only what
552
+ Struct sends is changed; your own exporters still see the real values.
553
+ Metrics, log bodies and span names are not redacted.
554
+
555
+ ## Full-app mode
556
+
557
+ Full-app mode is the default since 0.3.0: `struct.init()` adds APM-style
558
+ telemetry for the whole service, like an OpenTelemetry distro: HTTP servers and
559
+ clients, databases, caches, queues, app logs and process metrics, built on the
560
+ OpenTelemetry contrib instrumentors. Every supported library you have installed
561
+ is instrumented; turn individual ones off below. To keep the 0.2 behavior
562
+ (AI libraries only, isolated providers), pass `auto_instrument=False`.
563
+
564
+ ```bash
565
+ pip install "struct-sdk[auto]" # needs opentelemetry-sdk 1.30+; add [azure] for Azure SDK tracing,
566
+ # [structlog] for structlog (opentelemetry-sdk 1.44+),
567
+ # [exceptions] for uncaught exceptions (1.43+)
568
+ ```
569
+
570
+ ```python
571
+ import fastapi
572
+ from struct_sdk import struct
573
+
574
+ struct.init(ingest_key="pk-...", service_name="checkout-api")
575
+
576
+ app = fastapi.FastAPI() # resolve the class after init()
577
+ ```
578
+
579
+ Framework instrumentors replace the framework's class when `init()` runs, so
580
+ an app class bound earlier stays uninstrumented: `from fastapi import FastAPI`
581
+ above `struct.init()` keeps the original class. Import framework classes after
582
+ `init()`, or reference them through the module as above. For an app created
583
+ before `init()`, call the contrib instrumentor directly (for example
584
+ `FastAPIInstrumentor.instrument_app(app)`). In pre-fork servers, run
585
+ `init()` in each worker, as described above.
586
+
587
+ | Signal | Coverage |
588
+ |---|---|
589
+ | Traces | Django, FastAPI, Flask, Starlette, Tornado, Falcon, Pyramid, aiohttp (client and server), requests, httpx, urllib, urllib3, gRPC, psycopg2, psycopg, aiopg, asyncpg, PyMySQL, MySQL Connector, mysqlclient, pymssql, sqlite3, PyMongo, Cassandra, Redis, pymemcache, Tortoise ORM, Celery, botocore, aiobotocore, Kafka (confluent-kafka, aiokafka, kafka-python), RabbitMQ (pika, aio-pika), Jinja2, Azure SDK. Work handed to threads and `ThreadPoolExecutor` stays in the caller's trace. SQLAlchemy is off by default because its query spans duplicate the database driver's; enable it with `{"sqlalchemy": {"enabled": True}}` if you prefer ORM-level spans. |
590
+ | Logs | Python `logging` records that reach the root logger, with trace context. The SDK's own and OpenTelemetry's records are excluded. With the `[structlog]` extra, structlog events that structlog writes itself (its default) are exported with their fields as attributes; events structlog hands to `logging` (`structlog.stdlib.LoggerFactory`) are exported once, through the `logging` bridge. Extras and structlog fields OpenTelemetry can't take as-is are exported as strings: dicts and mixed-type lists as JSON, everything else (UUIDs, datetimes, `None`, bytes) as its `str()`. Your other handlers still see the original values. With the `[exceptions]` extra, uncaught exceptions (main thread, other threads, asyncio) are exported as log records. If your app configures no logging, its warnings still print to stderr as they would without Struct. This is separate from `content_capture`, which only governs LLM content; disable `{"logging": {"enabled": False}}` and `{"structlog": {"enabled": False}}` (which also stops copies of your logs when Struct attaches to your OpenTelemetry setup), or redact attributes, if application logs can carry sensitive data. |
591
+ | Metrics | Instrumentor metrics (for example `http.server.duration`) and process metrics (`process.cpu.*`, `process.memory.*`, threads, file descriptors, GC). Counters and histograms use delta temporality. |
592
+
593
+ Each instrumentor activates only when its library is installed.
594
+ `instrumentation_options` disables or configures one by name; other keys are
595
+ passed to that instrumentor:
596
+
597
+ ```python
598
+ struct.init(
599
+ ingest_key="pk-...",
600
+ instrumentation_options={
601
+ "fastapi": {"excluded_urls": "health,ready"},
602
+ "logging": {"enabled": False},
603
+ },
604
+ )
605
+ ```
606
+
607
+ As with `opentelemetry-instrument` and the Azure Monitor distro, the standard
608
+ `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS` environment variable also turns
609
+ libraries off (`OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=requests,urllib3`, or
610
+ `*` for all of them). An explicit `instrumentation_options` entry takes
611
+ precedence over it.
612
+
613
+ Full-app mode installs Struct's providers as the global OpenTelemetry
614
+ providers only when no tracer, meter or logger provider is installed. If the
615
+ process already configured any of them (Azure Monitor, your own SDK setup),
616
+ Struct adds its exporters to your SDK tracer and logger providers and
617
+ instruments nothing: your setup keeps ownership of instrumentation, sampling
618
+ and shutdown, and your metrics are not sent to Struct. Struct's own GenAI
619
+ spans keep their isolated provider either way, so your sampler does not drop
620
+ them.
621
+
622
+ ### Datadog ddtrace
623
+
624
+ Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
625
+ `urllib3` beneath `requests` or `botocore`, to avoid duplicate spans and
626
+ conflicting trace headers, and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
627
+ ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
628
+ ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
629
+ mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
630
+
631
+ ### Zero-code activation
632
+
633
+ ```bash
634
+ OTEL_PYTHON_DISTRO=struct \
635
+ STRUCT_INGEST_KEY=pk-... \
636
+ OTEL_SERVICE_NAME=checkout-api \
637
+ opentelemetry-instrument python app.py
638
+ ```
639
+
640
+ `opentelemetry-instrument` comes with `[auto]`. Struct calls `struct.init()`
641
+ before your app starts, with `STRUCT_INGEST_URL` as the endpoint when set.
642
+ Message content stays off; call `struct.init()` in code to turn it on.
643
+ Installing the SDK never changes another distro: Struct's distro is used only
644
+ when `OTEL_PYTHON_DISTRO=struct`. When selected, it also keeps other installed
645
+ configurators (such as `opentelemetry-distro`'s) from installing competing
646
+ providers, unless you set `OTEL_PYTHON_CONFIGURATOR` yourself. If you already run another distro, call
647
+ `struct.init()` in code instead and Struct attaches to its providers.
648
+
649
+ ### Temporal
650
+
651
+ ```bash
652
+ pip install "struct-sdk[temporal]"
653
+ ```
654
+
655
+ ```python
656
+ from temporalio.client import Client
657
+ from temporalio.runtime import Runtime
658
+ from struct_sdk import struct, temporal
659
+
660
+ struct.init(ingest_key="pk-...", service_name="billing-worker")
661
+ runtime = Runtime(telemetry=temporal.telemetry_config(global_tags={"replica": replica_id}))
662
+ client = await Client.connect(
663
+ "temporal:7233",
664
+ runtime=runtime,
665
+ interceptors=[temporal.tracing_interceptor()],
666
+ )
667
+ ```
668
+
669
+ `telemetry_config()` exports Temporal Core metrics (task slots,
670
+ schedule-to-start latency, poll results, sticky cache) to Struct. It keeps
671
+ Core's console logging at Temporal's defaults, except that failed metric
672
+ exports are not printed. `tracing_interceptor()` (temporalio 1.11+) traces
673
+ workflows and activities with Struct's tracer; a failing span processor never
674
+ blocks a Temporal call or replaces its exception.
675
+ Temporal server metrics are a Prometheus endpoint; scrape them with an
676
+ OpenTelemetry Collector and export OTLP/HTTP to your Struct ingest endpoint
677
+ with the `x-struct-ingest-key` header.
678
+
679
+ ## Reliability
680
+
681
+ The SDK is designed never to break your application. Instrumentation
682
+ hooks, span exports, and shutdown all swallow exceptions internally and log
683
+ at DEBUG on the `struct_sdk` logger, with a traceback for the first failure
684
+ at each site.
685
+ Process exit is bounded by `shutdown_timeout_seconds` and runs in a
686
+ background thread, so a slow or unreachable ingest endpoint cannot hang
687
+ shutdown.
688
+
689
+ ## Troubleshooting
690
+
691
+ - **Spans missing after instrumenting:** Call `struct.init()` *before*
692
+ importing the instrumented libraries, so the SDK can wire up
693
+ instrumentation before any instance is constructed.
694
+ - **No message log records appearing:** they only emit when the capture
695
+ mode is `EVENT_ONLY` or `SPAN_AND_EVENT`. The default is `NONE`.
696
+ - **Duplicate chat spans:** When an LLM provider SDK and a wrapping
697
+ framework are both instrumented (e.g. `ChatAnthropic` calling through
698
+ to `anthropic`), the provider-level chat span is skipped and the
699
+ framework's is kept.
700
+
701
+ ## License
702
+
703
+ Apache-2.0