struct-sdk 0.3.1__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.
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/PKG-INFO +41 -3
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/README.md +38 -2
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/pyproject.toml +8 -2
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/_genai_content.py +87 -31
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/core.py +19 -5
- struct_sdk-0.3.2/src/struct_sdk/pydantic_ai.py +914 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/.gitignore +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/LICENSE +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/_auto.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/_distro.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/anthropic.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/langchain.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/openai.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.3.2}/src/struct_sdk/temporal.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: struct-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
|
|
5
5
|
Project-URL: Homepage, https://struct.ai
|
|
6
6
|
Project-URL: Documentation, https://struct.ai/docs
|
|
@@ -92,6 +92,8 @@ Provides-Extra: langchain
|
|
|
92
92
|
Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
|
|
93
93
|
Provides-Extra: openai
|
|
94
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'
|
|
95
97
|
Provides-Extra: structlog
|
|
96
98
|
Requires-Dist: opentelemetry-instrumentation-structlog; extra == 'structlog'
|
|
97
99
|
Provides-Extra: temporal
|
|
@@ -102,8 +104,8 @@ Description-Content-Type: text/markdown
|
|
|
102
104
|
|
|
103
105
|
OpenTelemetry instrumentation for AI agents in Python. Captures spans,
|
|
104
106
|
token usage, and message events from the Anthropic SDK, the OpenAI SDK
|
|
105
|
-
(Responses API), the Claude Agent SDK, and LangChain /
|
|
106
|
-
exports them to [struct.ai](https://struct.ai) for observability.
|
|
107
|
+
(Responses API), pydantic-ai, the Claude Agent SDK, and LangChain /
|
|
108
|
+
LangGraph, and exports them to [struct.ai](https://struct.ai) for observability.
|
|
107
109
|
|
|
108
110
|
A TypeScript version is available as
|
|
109
111
|
[`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
|
|
@@ -117,6 +119,7 @@ pip install struct-sdk
|
|
|
117
119
|
# optional — the SDK auto-instruments these if present
|
|
118
120
|
pip install anthropic
|
|
119
121
|
pip install openai
|
|
122
|
+
pip install pydantic-ai-slim
|
|
120
123
|
pip install claude-agent-sdk
|
|
121
124
|
pip install langchain-core langgraph
|
|
122
125
|
```
|
|
@@ -162,6 +165,7 @@ async with struct.agent(name="checkout"):
|
|
|
162
165
|
|---|---|---|
|
|
163
166
|
| `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
|
|
164
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). |
|
|
165
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. |
|
|
166
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. |
|
|
167
171
|
| `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
|
|
@@ -202,6 +206,40 @@ from claude_agent_sdk import ClaudeAgentOptions, query
|
|
|
202
206
|
# credentials on ClaudeAgentOptions; subagents inherit them automatically.
|
|
203
207
|
```
|
|
204
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
|
+
|
|
205
243
|
#### LangChain / LangGraph (with an agent or graph)
|
|
206
244
|
|
|
207
245
|
```python
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
OpenTelemetry instrumentation for AI agents in Python. Captures spans,
|
|
4
4
|
token usage, and message events from the Anthropic SDK, the OpenAI SDK
|
|
5
|
-
(Responses API), the Claude Agent SDK, and LangChain /
|
|
6
|
-
exports them to [struct.ai](https://struct.ai) for observability.
|
|
5
|
+
(Responses API), pydantic-ai, the Claude Agent SDK, and LangChain /
|
|
6
|
+
LangGraph, and exports them to [struct.ai](https://struct.ai) for observability.
|
|
7
7
|
|
|
8
8
|
A TypeScript version is available as
|
|
9
9
|
[`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
|
|
@@ -17,6 +17,7 @@ pip install struct-sdk
|
|
|
17
17
|
# optional — the SDK auto-instruments these if present
|
|
18
18
|
pip install anthropic
|
|
19
19
|
pip install openai
|
|
20
|
+
pip install pydantic-ai-slim
|
|
20
21
|
pip install claude-agent-sdk
|
|
21
22
|
pip install langchain-core langgraph
|
|
22
23
|
```
|
|
@@ -62,6 +63,7 @@ async with struct.agent(name="checkout"):
|
|
|
62
63
|
|---|---|---|
|
|
63
64
|
| `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
|
|
64
65
|
| `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). |
|
|
66
|
+
| `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). |
|
|
65
67
|
| `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. |
|
|
66
68
|
| `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. |
|
|
67
69
|
| `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
|
|
@@ -102,6 +104,40 @@ from claude_agent_sdk import ClaudeAgentOptions, query
|
|
|
102
104
|
# credentials on ClaudeAgentOptions; subagents inherit them automatically.
|
|
103
105
|
```
|
|
104
106
|
|
|
107
|
+
#### pydantic-ai
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from struct_sdk import struct
|
|
111
|
+
struct.init(ingest_key="pk-...", service_name="support-agent")
|
|
112
|
+
|
|
113
|
+
from pydantic_ai import Agent
|
|
114
|
+
agent = Agent("openai:gpt-5.5", name="support")
|
|
115
|
+
result = await agent.run("Refund order 42", conversation_id="thread-42")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
pydantic-ai already emits OpenTelemetry GenAI spans; `struct.init()` turns
|
|
119
|
+
that on for every agent (`Agent.instrument_all`) with Struct's tracer
|
|
120
|
+
provider, unless you already set your own default (for example with
|
|
121
|
+
logfire). You get pydantic-ai's `invoke_agent`, `chat` and `execute_tool`
|
|
122
|
+
spans, with one `chat` span per model call (Struct's OpenAI and Anthropic
|
|
123
|
+
patches stay out of the way for pydantic-ai's own provider calls; model
|
|
124
|
+
calls your code makes while reading a stream keep their spans). During agent
|
|
125
|
+
runs, Struct guards telemetry faults and caps content. Realtime sessions and
|
|
126
|
+
direct `InstrumentedModel` exception recording are outside this guarantee.
|
|
127
|
+
|
|
128
|
+
- **Content** goes on span attributes only (pydantic-ai has no message
|
|
129
|
+
events): `span_only` / `span_and_event` include prompts, answers and tool
|
|
130
|
+
arguments; `event_only` and `none` do not. Tool definitions are always
|
|
131
|
+
recorded.
|
|
132
|
+
- **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
|
|
133
|
+
around the run wins; otherwise pydantic-ai's `conversation_id` is used.
|
|
134
|
+
Instrumentation configured on your own provider keeps its conversation IDs.
|
|
135
|
+
- **Turn it off** with
|
|
136
|
+
`struct.init(..., instrumentation_options={"pydantic_ai": {"enabled": False}})`,
|
|
137
|
+
or for one agent with `agent.instrument = False`. An
|
|
138
|
+
`Agent.instrument_all(False)` before `struct.init()` is pydantic-ai's
|
|
139
|
+
initial value, so it does not keep pydantic-ai off.
|
|
140
|
+
|
|
105
141
|
#### LangChain / LangGraph (with an agent or graph)
|
|
106
142
|
|
|
107
143
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "struct-sdk"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -48,6 +48,9 @@ claude-agent-sdk = ["claude-agent-sdk>=0.2.121", "mcp>=1.30.0,<2"]
|
|
|
48
48
|
langchain = ["langchain-core>=1.3.3"]
|
|
49
49
|
# Responses API (openai.resources.responses) exists from 1.66.0 onward.
|
|
50
50
|
openai = ["openai>=1.66.0"]
|
|
51
|
+
# pydantic-ai's own OpenTelemetry instrumentation, driven with Struct's
|
|
52
|
+
# provider. 2.53.0 is the oldest release the suite runs against.
|
|
53
|
+
pydantic-ai = ["pydantic-ai-slim>=2.53.0"]
|
|
51
54
|
# Full-app mode (the default). Each instrumentor only activates when its
|
|
52
55
|
# target library is installed. Otherwise unpinned so the resolver picks the
|
|
53
56
|
# release matching the host's opentelemetry-sdk. The floor is contrib 0.51b0
|
|
@@ -154,6 +157,9 @@ dev = [
|
|
|
154
157
|
# Zero-code activation is tested next to the standard distro's configurator.
|
|
155
158
|
"opentelemetry-distro",
|
|
156
159
|
"temporalio>=1.11.0",
|
|
160
|
+
# pydantic-ai conformance suite (tests/test_pydantic_ai.py): real Agent
|
|
161
|
+
# runs, and pydantic-ai's OpenAI model over the transport-mocked client.
|
|
162
|
+
"pydantic-ai-slim[openai]>=2.53.0",
|
|
157
163
|
]
|
|
158
164
|
|
|
159
165
|
[tool.uv]
|
|
@@ -177,7 +183,7 @@ markers = [
|
|
|
177
183
|
|
|
178
184
|
[tool.mypy]
|
|
179
185
|
[[tool.mypy.overrides]]
|
|
180
|
-
module = ["anthropic", "anthropic.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*", "opentelemetry.instrumentation.*", "azure.core.*", "temporalio", "temporalio.*", "requests", "requests.*", "opentelemetry.resource.*", "ddtrace", "ddtrace.*"]
|
|
186
|
+
module = ["anthropic", "anthropic.*", "pydantic_ai", "pydantic_ai.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*", "opentelemetry.instrumentation.*", "azure.core.*", "temporalio", "temporalio.*", "requests", "requests.*", "opentelemetry.resource.*", "ddtrace", "ddtrace.*"]
|
|
181
187
|
ignore_missing_imports = true
|
|
182
188
|
|
|
183
189
|
[tool.hatch.build.targets.wheel]
|
|
@@ -60,29 +60,26 @@ def truncate_field(value: "str | Any", max_len: int = _MAX_FIELD_SIZE) -> "str |
|
|
|
60
60
|
return value
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
# Part fields that hold a structured payload (tool arguments and results), capped as
|
|
64
|
+
# serialized JSON. pydantic-ai puts a tool's output under ``result``, OTel under
|
|
65
|
+
# ``response``.
|
|
66
|
+
_STRUCTURED_PART_FIELDS = ("arguments", "response", "result")
|
|
67
|
+
|
|
68
|
+
|
|
63
69
|
def truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
64
|
-
"""
|
|
70
|
+
"""Cap every field of message parts: any string field (text, reasoning, ids, URIs,
|
|
71
|
+
inline data, tool results) at 16 KiB, and a structured tool payload at 16 KiB of
|
|
72
|
+
its JSON. Keys are kept."""
|
|
65
73
|
result = []
|
|
66
74
|
for part in parts:
|
|
67
75
|
part = dict(part) # shallow copy
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
part["arguments"] = truncate_field(arg)
|
|
74
|
-
elif isinstance(arg, dict):
|
|
75
|
-
serialized = json.dumps(arg, default=str)
|
|
76
|
+
for key, value in part.items():
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
part[key] = truncate_field(value)
|
|
79
|
+
elif key in _STRUCTURED_PART_FIELDS and isinstance(value, (dict, list)):
|
|
80
|
+
serialized = json.dumps(value, default=str)
|
|
76
81
|
if len(serialized) > _MAX_FIELD_SIZE:
|
|
77
|
-
part[
|
|
78
|
-
if "response" in part:
|
|
79
|
-
resp = part["response"]
|
|
80
|
-
if isinstance(resp, str):
|
|
81
|
-
part["response"] = truncate_field(resp)
|
|
82
|
-
elif isinstance(resp, (dict, list)):
|
|
83
|
-
serialized = json.dumps(resp, default=str)
|
|
84
|
-
if len(serialized) > _MAX_FIELD_SIZE:
|
|
85
|
-
part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
82
|
+
part[key] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
86
83
|
result.append(part)
|
|
87
84
|
return result
|
|
88
85
|
|
|
@@ -92,10 +89,10 @@ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
|
|
|
92
89
|
|
|
93
90
|
Unlike a blind string slice, this preserves valid JSON by truncating
|
|
94
91
|
content/arguments/response fields within parts before serialization.
|
|
95
|
-
If the result still exceeds ``max_size`` after field truncation,
|
|
96
|
-
|
|
92
|
+
If the result still exceeds ``max_size`` after field truncation, keeps the
|
|
93
|
+
whole leading array elements that fit, so the result stays valid JSON.
|
|
97
94
|
"""
|
|
98
|
-
if isinstance(obj, list):
|
|
95
|
+
if isinstance(obj, (list, tuple)):
|
|
99
96
|
truncated = []
|
|
100
97
|
for item in obj:
|
|
101
98
|
if isinstance(item, dict):
|
|
@@ -110,19 +107,72 @@ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
|
|
|
110
107
|
else:
|
|
111
108
|
result = json.dumps(obj, default=str)
|
|
112
109
|
|
|
113
|
-
#
|
|
114
|
-
#
|
|
110
|
+
# Still too large after field-level truncation: keep whole leading array
|
|
111
|
+
# elements that fit, so the result is always valid JSON. (Cutting at the last
|
|
112
|
+
# "}" could land inside a nested part and leave its message open.)
|
|
115
113
|
if len(result) > max_size:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
if not isinstance(obj, (list, tuple)):
|
|
115
|
+
return "[]"
|
|
116
|
+
kept: list[str] = []
|
|
117
|
+
size = 2 # the enclosing brackets
|
|
118
|
+
for item in truncated:
|
|
119
|
+
encoded = json.dumps(item, default=str)
|
|
120
|
+
if not kept and size + len(encoded) > max_size:
|
|
121
|
+
# A first message too large on its own keeps its whole leading
|
|
122
|
+
# parts instead, so a single oversize message is never dropped.
|
|
123
|
+
encoded = _leading_parts_that_fit(item, max_size - size) or ""
|
|
124
|
+
if encoded:
|
|
125
|
+
kept.append(encoded)
|
|
126
|
+
break
|
|
127
|
+
extra = len(encoded) + (2 if kept else 0) # ", " between elements
|
|
128
|
+
if size + extra > max_size:
|
|
129
|
+
break
|
|
130
|
+
kept.append(encoded)
|
|
131
|
+
size += extra
|
|
132
|
+
result = "[" + ", ".join(kept) + "]"
|
|
122
133
|
|
|
123
134
|
return result
|
|
124
135
|
|
|
125
136
|
|
|
137
|
+
def _leading_parts_that_fit(item: Any, budget: int) -> Optional[str]:
|
|
138
|
+
"""``item`` (a message) serialized with only the leading ``parts`` that fit in
|
|
139
|
+
``budget`` characters, or ``None`` when it has no parts or none fit."""
|
|
140
|
+
if not isinstance(item, dict) or not isinstance(item.get("parts"), list):
|
|
141
|
+
return None
|
|
142
|
+
# Linear: the envelope and each part are serialized once (re-serializing the
|
|
143
|
+
# growing prefix per part was quadratic on the host's request path).
|
|
144
|
+
marker = "\x00struct-parts\x00"
|
|
145
|
+
envelope = json.dumps({**item, "parts": marker}, default=str)
|
|
146
|
+
marker_json = json.dumps(marker)
|
|
147
|
+
if envelope.count(marker_json) != 1: # the message itself holds the marker text
|
|
148
|
+
return _leading_parts_that_fit_slow(item, budget)
|
|
149
|
+
size = len(envelope) - len(marker_json) + 2 # the parts list's brackets
|
|
150
|
+
kept: list[str] = []
|
|
151
|
+
for part in item["parts"]:
|
|
152
|
+
encoded = json.dumps(part, default=str)
|
|
153
|
+
extra = len(encoded) + (2 if kept else 0) # ", " between parts
|
|
154
|
+
if size + extra > budget:
|
|
155
|
+
break
|
|
156
|
+
kept.append(encoded)
|
|
157
|
+
size += extra
|
|
158
|
+
if not kept:
|
|
159
|
+
return None
|
|
160
|
+
return envelope.replace(marker_json, "[" + ", ".join(kept) + "]", 1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _leading_parts_that_fit_slow(item: Any, budget: int) -> Optional[str]:
|
|
164
|
+
"""``_leading_parts_that_fit`` for a message containing its marker text."""
|
|
165
|
+
parts: list[Any] = []
|
|
166
|
+
best: Optional[str] = None
|
|
167
|
+
for part in item["parts"]:
|
|
168
|
+
candidate = json.dumps({**item, "parts": [*parts, part]}, default=str)
|
|
169
|
+
if len(candidate) > budget:
|
|
170
|
+
break
|
|
171
|
+
parts.append(part)
|
|
172
|
+
best = candidate
|
|
173
|
+
return best
|
|
174
|
+
|
|
175
|
+
|
|
126
176
|
def safe_json(obj: Any) -> str:
|
|
127
177
|
"""Safely serialize to JSON string with field-level truncation."""
|
|
128
178
|
try:
|
|
@@ -578,9 +628,15 @@ def stamp_provider_once(agent_span: Any, provider: Optional[str]) -> None:
|
|
|
578
628
|
|
|
579
629
|
|
|
580
630
|
def propagate_provider_to_parent(provider: Optional[str]) -> None:
|
|
581
|
-
"""Stamp the ambient ``_current_agent_span`` with the child call's provider
|
|
631
|
+
"""Stamp the ambient ``_current_agent_span`` with the child call's provider,
|
|
632
|
+
unless a framework span that stamps it itself owns this call (SDK-PY-9)."""
|
|
582
633
|
try:
|
|
583
|
-
from
|
|
634
|
+
from opentelemetry import context as otel_context
|
|
635
|
+
|
|
636
|
+
from struct_sdk.core import _PROVIDER_STAMP_OWNED_KEY, _current_agent_span
|
|
637
|
+
|
|
638
|
+
if otel_context.get_value(_PROVIDER_STAMP_OWNED_KEY):
|
|
639
|
+
return
|
|
584
640
|
stamp_provider_once(_current_agent_span.get(None), provider)
|
|
585
641
|
except Exception:
|
|
586
642
|
pass # Never break the application for telemetry
|
|
@@ -190,6 +190,10 @@ _pending_tool_calls: contextvars.ContextVar[Optional[dict[str, list[str]]]] = co
|
|
|
190
190
|
# and whose contextvar token could raise ``ValueError: Token created in a different
|
|
191
191
|
# Context`` when detached from a different async context).
|
|
192
192
|
_GENAI_SUPPRESS_KEY = create_key("struct.suppress_genai")
|
|
193
|
+
# Set with the suppression key when the owning framework span stamps the agent's
|
|
194
|
+
# provider itself (pydantic-ai, from the call that answered): a stood-down provider
|
|
195
|
+
# patch then leaves the agent span's provider to it (SDK-PY-9).
|
|
196
|
+
_PROVIDER_STAMP_OWNED_KEY = _otel_context.create_key("struct_provider_stamp_owned")
|
|
193
197
|
|
|
194
198
|
|
|
195
199
|
def is_genai_suppressed() -> bool:
|
|
@@ -199,7 +203,7 @@ def is_genai_suppressed() -> bool:
|
|
|
199
203
|
return bool(_otel_context.get_value(_GENAI_SUPPRESS_KEY))
|
|
200
204
|
|
|
201
205
|
|
|
202
|
-
def suppress_genai_token() -> "tuple[Token[_OtelContext], Optional[asyncio.Task[Any]]]":
|
|
206
|
+
def suppress_genai_token(*, owns_provider_stamp: bool = False) -> "tuple[Token[_OtelContext], Optional[asyncio.Task[Any]]]":
|
|
203
207
|
"""Attach the suppression key to the current OTel context.
|
|
204
208
|
|
|
205
209
|
Returns ``(token, enter_task)`` that MUST be passed to ``reset_genai()`` when
|
|
@@ -208,7 +212,10 @@ def suppress_genai_token() -> "tuple[Token[_OtelContext], Optional[asyncio.Task[
|
|
|
208
212
|
schedule on DIFFERENT asyncio tasks, so the enter-task travels with the token
|
|
209
213
|
for a task-affinity-safe detach.
|
|
210
214
|
"""
|
|
211
|
-
|
|
215
|
+
context = _otel_context.set_value(_GENAI_SUPPRESS_KEY, True)
|
|
216
|
+
if owns_provider_stamp:
|
|
217
|
+
context = _otel_context.set_value(_PROVIDER_STAMP_OWNED_KEY, True, context)
|
|
218
|
+
token = _otel_context.attach(context)
|
|
212
219
|
return token, _current_task_or_none()
|
|
213
220
|
|
|
214
221
|
|
|
@@ -812,6 +819,9 @@ class StructSDK:
|
|
|
812
819
|
Other keys are passed to that instrumentor's ``instrument()``.
|
|
813
820
|
Names listed in ``OTEL_PYTHON_DISABLED_INSTRUMENTATIONS`` (or
|
|
814
821
|
``*``) default to disabled; an explicit entry here wins.
|
|
822
|
+
``{"enabled": False}`` also turns off a GenAI integration in
|
|
823
|
+
any mode: ``anthropic``, ``claude_agent_sdk``,
|
|
824
|
+
``langchain_core``, ``openai``, ``pydantic_ai``.
|
|
815
825
|
redact_attributes: Glob patterns (case-insensitive) for span,
|
|
816
826
|
span-event and log attributes whose values Struct sends as
|
|
817
827
|
"[REDACTED]", e.g. ``["http.request.header.authorization",
|
|
@@ -878,7 +888,7 @@ class StructSDK:
|
|
|
878
888
|
global _init_pid
|
|
879
889
|
_init_pid = os.getpid()
|
|
880
890
|
|
|
881
|
-
self._auto_instrument()
|
|
891
|
+
self._auto_instrument(instrumentation_options or {})
|
|
882
892
|
except Exception:
|
|
883
893
|
logger.warning(
|
|
884
894
|
"Struct SDK init failed; SDK disabled: service=%s endpoint=%s",
|
|
@@ -905,18 +915,22 @@ class StructSDK:
|
|
|
905
915
|
self._service_name, endpoint, self._content_capture.value,
|
|
906
916
|
)
|
|
907
917
|
|
|
908
|
-
def _auto_instrument(self):
|
|
909
|
-
"""Detect and patch installed libraries
|
|
918
|
+
def _auto_instrument(self, options: Optional[Mapping[str, Mapping[str, Any]]] = None):
|
|
919
|
+
"""Detect and patch installed libraries, except those turned off with
|
|
920
|
+
``instrumentation_options={name: {"enabled": False}}``."""
|
|
910
921
|
integrations = [
|
|
911
922
|
("anthropic", "struct_sdk.anthropic"),
|
|
912
923
|
("claude_agent_sdk", "struct_sdk.claude_agent"),
|
|
913
924
|
("langchain_core", "struct_sdk.langchain"),
|
|
914
925
|
("openai", "struct_sdk.openai"),
|
|
926
|
+
("pydantic_ai", "struct_sdk.pydantic_ai"),
|
|
915
927
|
]
|
|
916
928
|
|
|
917
929
|
for lib_name, module_path in integrations:
|
|
918
930
|
if lib_name in _patched_integrations:
|
|
919
931
|
continue
|
|
932
|
+
if (options or {}).get(lib_name, {}).get("enabled", True) is False:
|
|
933
|
+
continue
|
|
920
934
|
try:
|
|
921
935
|
import importlib
|
|
922
936
|
mod = importlib.import_module(module_path)
|