struct-sdk 0.3.1__tar.gz → 0.4.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.4.2}/.gitignore +3 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/PKG-INFO +46 -8
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/README.md +43 -7
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/pyproject.toml +8 -2
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/_genai_content.py +89 -145
- struct_sdk-0.4.2/src/struct_sdk/_operation_details.py +293 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/anthropic.py +25 -161
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/core.py +25 -6
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/langchain.py +10 -158
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/openai.py +26 -127
- struct_sdk-0.4.2/src/struct_sdk/pydantic_ai.py +991 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/LICENSE +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/_auto.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/_distro.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.2}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.3.1 → struct_sdk-0.4.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
|
+
Version: 0.4.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
|
|
@@ -101,9 +103,9 @@ Description-Content-Type: text/markdown
|
|
|
101
103
|
# struct-sdk
|
|
102
104
|
|
|
103
105
|
OpenTelemetry instrumentation for AI agents in Python. Captures spans,
|
|
104
|
-
token usage, and message
|
|
105
|
-
(Responses API), the Claude Agent SDK, and LangChain /
|
|
106
|
-
exports them to [struct.ai](https://struct.ai) for observability.
|
|
106
|
+
token usage, and message content 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.
|
|
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
|
```
|
|
@@ -161,7 +164,8 @@ async with struct.agent(name="checkout"):
|
|
|
161
164
|
| Library | Span type | Notes |
|
|
162
165
|
|---|---|---|
|
|
163
166
|
| `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
|
|
164
|
-
| `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:
|
|
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: in event modes, one `gen_ai.client.inference.operation.details` record per call; in span modes, `gen_ai.input.messages` / `gen_ai.output.messages` on the span. 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,39 @@ 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:** in event modes, each chat call's messages go to one
|
|
231
|
+
`operation.details` event. Span modes keep chat content on spans. Tool
|
|
232
|
+
arguments and results remain on `execute_tool` spans in every mode but `none`.
|
|
233
|
+
- **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
|
|
234
|
+
around the run wins; otherwise pydantic-ai's `conversation_id` is used.
|
|
235
|
+
Instrumentation configured on your own provider keeps its conversation IDs.
|
|
236
|
+
- **Turn it off** with
|
|
237
|
+
`struct.init(..., instrumentation_options={"pydantic_ai": {"enabled": False}})`,
|
|
238
|
+
or for one agent with `agent.instrument = False`. An
|
|
239
|
+
`Agent.instrument_all(False)` before `struct.init()` is pydantic-ai's
|
|
240
|
+
initial value, so it does not keep pydantic-ai off.
|
|
241
|
+
|
|
205
242
|
#### LangChain / LangGraph (with an agent or graph)
|
|
206
243
|
|
|
207
244
|
```python
|
|
@@ -373,9 +410,10 @@ struct.init(
|
|
|
373
410
|
)
|
|
374
411
|
```
|
|
375
412
|
|
|
376
|
-
- **`EVENT_ONLY`**:
|
|
377
|
-
|
|
378
|
-
|
|
413
|
+
- **`EVENT_ONLY`**: each model call's content lands on one OTel log record,
|
|
414
|
+
`gen_ai.client.inference.operation.details` (input and output messages,
|
|
415
|
+
system instructions, tool definitions, as structured values). Spans carry
|
|
416
|
+
metadata only.
|
|
379
417
|
- **`SPAN_ONLY`**: content on span attributes (`gen_ai.input.messages`,
|
|
380
418
|
`gen_ai.output.messages`).
|
|
381
419
|
- **`SPAN_AND_EVENT`**: both.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# struct-sdk
|
|
2
2
|
|
|
3
3
|
OpenTelemetry instrumentation for AI agents in Python. Captures spans,
|
|
4
|
-
token usage, and message
|
|
5
|
-
(Responses API), the Claude Agent SDK, and LangChain /
|
|
6
|
-
exports them to [struct.ai](https://struct.ai) for observability.
|
|
4
|
+
token usage, and message content from the Anthropic SDK, the OpenAI SDK
|
|
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
|
```
|
|
@@ -61,7 +62,8 @@ async with struct.agent(name="checkout"):
|
|
|
61
62
|
| Library | Span type | Notes |
|
|
62
63
|
|---|---|---|
|
|
63
64
|
| `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
|
|
64
|
-
| `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:
|
|
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: in event modes, one `gen_ai.client.inference.operation.details` record per call; in span modes, `gen_ai.input.messages` / `gen_ai.output.messages` on the span. 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,39 @@ 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:** in event modes, each chat call's messages go to one
|
|
129
|
+
`operation.details` event. Span modes keep chat content on spans. Tool
|
|
130
|
+
arguments and results remain on `execute_tool` spans in every mode but `none`.
|
|
131
|
+
- **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
|
|
132
|
+
around the run wins; otherwise pydantic-ai's `conversation_id` is used.
|
|
133
|
+
Instrumentation configured on your own provider keeps its conversation IDs.
|
|
134
|
+
- **Turn it off** with
|
|
135
|
+
`struct.init(..., instrumentation_options={"pydantic_ai": {"enabled": False}})`,
|
|
136
|
+
or for one agent with `agent.instrument = False`. An
|
|
137
|
+
`Agent.instrument_all(False)` before `struct.init()` is pydantic-ai's
|
|
138
|
+
initial value, so it does not keep pydantic-ai off.
|
|
139
|
+
|
|
105
140
|
#### LangChain / LangGraph (with an agent or graph)
|
|
106
141
|
|
|
107
142
|
```python
|
|
@@ -273,9 +308,10 @@ struct.init(
|
|
|
273
308
|
)
|
|
274
309
|
```
|
|
275
310
|
|
|
276
|
-
- **`EVENT_ONLY`**:
|
|
277
|
-
|
|
278
|
-
|
|
311
|
+
- **`EVENT_ONLY`**: each model call's content lands on one OTel log record,
|
|
312
|
+
`gen_ai.client.inference.operation.details` (input and output messages,
|
|
313
|
+
system instructions, tool definitions, as structured values). Spans carry
|
|
314
|
+
metadata only.
|
|
279
315
|
- **`SPAN_ONLY`**: content on span attributes (`gen_ai.input.messages`,
|
|
280
316
|
`gen_ai.output.messages`).
|
|
281
317
|
- **`SPAN_AND_EVENT`**: both.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "struct-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.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]
|
|
@@ -7,11 +7,8 @@ across providers once a message/response has been mapped to the spec's
|
|
|
7
7
|
|
|
8
8
|
- Truncation constants + helpers (``truncate_field`` / ``truncate_parts`` /
|
|
9
9
|
``truncate_and_serialize`` / ``safe_json``).
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
``parts`` and a ``provider`` string, so the OTel logs data-model wiring
|
|
13
|
-
(``body`` = event tag, ``attributes['body']`` = JSON payload, span-context
|
|
14
|
-
from the EXPLICIT span) lives in exactly one place.
|
|
10
|
+
- (Message content leaves the SDK through ``_operation_details.record_content``:
|
|
11
|
+
span attributes, or one ``operation.details`` record per call.)
|
|
15
12
|
- ``propagate_user_prompt_to_parent`` — write-once parent ``gen_ai.input.messages``
|
|
16
13
|
from the last user message's parts.
|
|
17
14
|
|
|
@@ -31,11 +28,9 @@ import contextlib
|
|
|
31
28
|
import json
|
|
32
29
|
import logging
|
|
33
30
|
import sys
|
|
34
|
-
import time
|
|
35
31
|
from typing import Any, Callable, Iterator, Optional
|
|
36
32
|
from urllib.parse import urlparse
|
|
37
33
|
|
|
38
|
-
from opentelemetry import trace
|
|
39
34
|
from opentelemetry.trace import StatusCode
|
|
40
35
|
|
|
41
36
|
from struct_sdk.core import _contain_logged
|
|
@@ -60,29 +55,26 @@ def truncate_field(value: "str | Any", max_len: int = _MAX_FIELD_SIZE) -> "str |
|
|
|
60
55
|
return value
|
|
61
56
|
|
|
62
57
|
|
|
58
|
+
# Part fields that hold a structured payload (tool arguments and results), capped as
|
|
59
|
+
# serialized JSON. pydantic-ai puts a tool's output under ``result``, OTel under
|
|
60
|
+
# ``response``.
|
|
61
|
+
_STRUCTURED_PART_FIELDS = ("arguments", "response", "result")
|
|
62
|
+
|
|
63
|
+
|
|
63
64
|
def truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
64
|
-
"""
|
|
65
|
+
"""Cap every field of message parts: any string field (text, reasoning, ids, URIs,
|
|
66
|
+
inline data, tool results) at 16 KiB, and a structured tool payload at 16 KiB of
|
|
67
|
+
its JSON. Keys are kept."""
|
|
65
68
|
result = []
|
|
66
69
|
for part in parts:
|
|
67
70
|
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
|
-
if len(serialized) > _MAX_FIELD_SIZE:
|
|
77
|
-
part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
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)
|
|
71
|
+
for key, value in part.items():
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
part[key] = truncate_field(value)
|
|
74
|
+
elif key in _STRUCTURED_PART_FIELDS and isinstance(value, (dict, list)):
|
|
75
|
+
serialized = json.dumps(value, default=str)
|
|
84
76
|
if len(serialized) > _MAX_FIELD_SIZE:
|
|
85
|
-
part[
|
|
77
|
+
part[key] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
86
78
|
result.append(part)
|
|
87
79
|
return result
|
|
88
80
|
|
|
@@ -92,10 +84,10 @@ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
|
|
|
92
84
|
|
|
93
85
|
Unlike a blind string slice, this preserves valid JSON by truncating
|
|
94
86
|
content/arguments/response fields within parts before serialization.
|
|
95
|
-
If the result still exceeds ``max_size`` after field truncation,
|
|
96
|
-
|
|
87
|
+
If the result still exceeds ``max_size`` after field truncation, keeps the
|
|
88
|
+
whole leading array elements that fit, so the result stays valid JSON.
|
|
97
89
|
"""
|
|
98
|
-
if isinstance(obj, list):
|
|
90
|
+
if isinstance(obj, (list, tuple)):
|
|
99
91
|
truncated = []
|
|
100
92
|
for item in obj:
|
|
101
93
|
if isinstance(item, dict):
|
|
@@ -110,19 +102,72 @@ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
|
|
|
110
102
|
else:
|
|
111
103
|
result = json.dumps(obj, default=str)
|
|
112
104
|
|
|
113
|
-
#
|
|
114
|
-
#
|
|
105
|
+
# Still too large after field-level truncation: keep whole leading array
|
|
106
|
+
# elements that fit, so the result is always valid JSON. (Cutting at the last
|
|
107
|
+
# "}" could land inside a nested part and leave its message open.)
|
|
115
108
|
if len(result) > max_size:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
109
|
+
if not isinstance(obj, (list, tuple)):
|
|
110
|
+
return "[]"
|
|
111
|
+
kept: list[str] = []
|
|
112
|
+
size = 2 # the enclosing brackets
|
|
113
|
+
for item in truncated:
|
|
114
|
+
encoded = json.dumps(item, default=str)
|
|
115
|
+
if not kept and size + len(encoded) > max_size:
|
|
116
|
+
# A first message too large on its own keeps its whole leading
|
|
117
|
+
# parts instead, so a single oversize message is never dropped.
|
|
118
|
+
encoded = _leading_parts_that_fit(item, max_size - size) or ""
|
|
119
|
+
if encoded:
|
|
120
|
+
kept.append(encoded)
|
|
121
|
+
break
|
|
122
|
+
extra = len(encoded) + (2 if kept else 0) # ", " between elements
|
|
123
|
+
if size + extra > max_size:
|
|
124
|
+
break
|
|
125
|
+
kept.append(encoded)
|
|
126
|
+
size += extra
|
|
127
|
+
result = "[" + ", ".join(kept) + "]"
|
|
122
128
|
|
|
123
129
|
return result
|
|
124
130
|
|
|
125
131
|
|
|
132
|
+
def _leading_parts_that_fit(item: Any, budget: int) -> Optional[str]:
|
|
133
|
+
"""``item`` (a message) serialized with only the leading ``parts`` that fit in
|
|
134
|
+
``budget`` characters, or ``None`` when it has no parts or none fit."""
|
|
135
|
+
if not isinstance(item, dict) or not isinstance(item.get("parts"), list):
|
|
136
|
+
return None
|
|
137
|
+
# Linear: the envelope and each part are serialized once (re-serializing the
|
|
138
|
+
# growing prefix per part was quadratic on the host's request path).
|
|
139
|
+
marker = "\x00struct-parts\x00"
|
|
140
|
+
envelope = json.dumps({**item, "parts": marker}, default=str)
|
|
141
|
+
marker_json = json.dumps(marker)
|
|
142
|
+
if envelope.count(marker_json) != 1: # the message itself holds the marker text
|
|
143
|
+
return _leading_parts_that_fit_slow(item, budget)
|
|
144
|
+
size = len(envelope) - len(marker_json) + 2 # the parts list's brackets
|
|
145
|
+
kept: list[str] = []
|
|
146
|
+
for part in item["parts"]:
|
|
147
|
+
encoded = json.dumps(part, default=str)
|
|
148
|
+
extra = len(encoded) + (2 if kept else 0) # ", " between parts
|
|
149
|
+
if size + extra > budget:
|
|
150
|
+
break
|
|
151
|
+
kept.append(encoded)
|
|
152
|
+
size += extra
|
|
153
|
+
if not kept:
|
|
154
|
+
return None
|
|
155
|
+
return envelope.replace(marker_json, "[" + ", ".join(kept) + "]", 1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _leading_parts_that_fit_slow(item: Any, budget: int) -> Optional[str]:
|
|
159
|
+
"""``_leading_parts_that_fit`` for a message containing its marker text."""
|
|
160
|
+
parts: list[Any] = []
|
|
161
|
+
best: Optional[str] = None
|
|
162
|
+
for part in item["parts"]:
|
|
163
|
+
candidate = json.dumps({**item, "parts": [*parts, part]}, default=str)
|
|
164
|
+
if len(candidate) > budget:
|
|
165
|
+
break
|
|
166
|
+
parts.append(part)
|
|
167
|
+
best = candidate
|
|
168
|
+
return best
|
|
169
|
+
|
|
170
|
+
|
|
126
171
|
def safe_json(obj: Any) -> str:
|
|
127
172
|
"""Safely serialize to JSON string with field-level truncation."""
|
|
128
173
|
try:
|
|
@@ -131,113 +176,6 @@ def safe_json(obj: Any) -> str:
|
|
|
131
176
|
return "[]"
|
|
132
177
|
|
|
133
178
|
|
|
134
|
-
# ---------------------------------------------------------------------------
|
|
135
|
-
# LogRecord emission — OTel logs data model
|
|
136
|
-
# ---------------------------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
def emit_message_event(
|
|
139
|
-
otel_logger: Any,
|
|
140
|
-
*,
|
|
141
|
-
role: str,
|
|
142
|
-
parts: list[dict[str, Any]],
|
|
143
|
-
event_name: str,
|
|
144
|
-
provider: str,
|
|
145
|
-
message_index: int,
|
|
146
|
-
span: Optional[trace.Span] = None,
|
|
147
|
-
) -> None:
|
|
148
|
-
"""Emit ONE per-message LogRecord linked to ``span``.
|
|
149
|
-
|
|
150
|
-
OTel logs data-model convention:
|
|
151
|
-
- ``body`` (log record body): the event tag (``gen_ai.user.message`` etc.).
|
|
152
|
-
- ``attributes['body']``: the JSON payload ``{"role": ..., "parts": [...]}``.
|
|
153
|
-
- Other attributes: ``event.name``, ``gen_ai.provider.name``,
|
|
154
|
-
``gen_ai.message.index``, and ``gen_ai.conversation.id`` when a session is
|
|
155
|
-
active.
|
|
156
|
-
|
|
157
|
-
Span-context is taken from the EXPLICIT ``span`` when given —
|
|
158
|
-
``trace.get_current_span()`` can drop the active span across the generator
|
|
159
|
-
wrapper's async awaits. All work is best-effort: a failure here NEVER
|
|
160
|
-
reaches the customer's call (it becomes a ``logger.debug``).
|
|
161
|
-
"""
|
|
162
|
-
try:
|
|
163
|
-
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
164
|
-
|
|
165
|
-
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
166
|
-
from struct_sdk.core import _current_session_id
|
|
167
|
-
session_id = _current_session_id.get(None)
|
|
168
|
-
|
|
169
|
-
payload = json.dumps({"role": role, "parts": truncate_parts(parts)}, default=str)
|
|
170
|
-
attrs: dict[str, Any] = {
|
|
171
|
-
"event.name": event_name,
|
|
172
|
-
"body": payload,
|
|
173
|
-
"gen_ai.provider.name": provider,
|
|
174
|
-
"gen_ai.message.index": message_index,
|
|
175
|
-
}
|
|
176
|
-
if session_id:
|
|
177
|
-
attrs["gen_ai.conversation.id"] = session_id
|
|
178
|
-
|
|
179
|
-
otel_logger.emit(LogRecord(
|
|
180
|
-
timestamp=int(time.time_ns()),
|
|
181
|
-
trace_id=span_ctx.trace_id,
|
|
182
|
-
span_id=span_ctx.span_id,
|
|
183
|
-
trace_flags=span_ctx.trace_flags,
|
|
184
|
-
severity_number=SeverityNumber.INFO,
|
|
185
|
-
body=event_name,
|
|
186
|
-
attributes=attrs,
|
|
187
|
-
))
|
|
188
|
-
except Exception:
|
|
189
|
-
logger.debug("Failed to emit message event", exc_info=True)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def emit_choice_event(
|
|
193
|
-
otel_logger: Any,
|
|
194
|
-
*,
|
|
195
|
-
parts: list[dict[str, Any]],
|
|
196
|
-
finish_reason: str,
|
|
197
|
-
provider: str,
|
|
198
|
-
span: Optional[trace.Span] = None,
|
|
199
|
-
) -> None:
|
|
200
|
-
"""Emit a ``gen_ai.choice`` LogRecord for the assistant's response.
|
|
201
|
-
|
|
202
|
-
``parts`` are already spec-shaped assistant parts and ``finish_reason`` is
|
|
203
|
-
already mapped to the spec name by the caller (mapping is provider-specific).
|
|
204
|
-
The ``choice`` event omits ``gen_ai.message.index``.
|
|
205
|
-
"""
|
|
206
|
-
try:
|
|
207
|
-
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
208
|
-
|
|
209
|
-
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
210
|
-
from struct_sdk.core import _current_session_id
|
|
211
|
-
session_id = _current_session_id.get(None)
|
|
212
|
-
|
|
213
|
-
choice_body = {
|
|
214
|
-
"index": 0,
|
|
215
|
-
"finish_reason": finish_reason or "stop",
|
|
216
|
-
"message": {"role": "assistant", "parts": truncate_parts(parts)},
|
|
217
|
-
}
|
|
218
|
-
payload = json.dumps(choice_body, default=str)
|
|
219
|
-
event_name = "gen_ai.choice"
|
|
220
|
-
attrs: dict[str, Any] = {
|
|
221
|
-
"event.name": event_name,
|
|
222
|
-
"body": payload,
|
|
223
|
-
"gen_ai.provider.name": provider,
|
|
224
|
-
}
|
|
225
|
-
if session_id:
|
|
226
|
-
attrs["gen_ai.conversation.id"] = session_id
|
|
227
|
-
|
|
228
|
-
otel_logger.emit(LogRecord(
|
|
229
|
-
timestamp=int(time.time_ns()),
|
|
230
|
-
trace_id=span_ctx.trace_id,
|
|
231
|
-
span_id=span_ctx.span_id,
|
|
232
|
-
trace_flags=span_ctx.trace_flags,
|
|
233
|
-
severity_number=SeverityNumber.INFO,
|
|
234
|
-
body=event_name,
|
|
235
|
-
attributes=attrs,
|
|
236
|
-
))
|
|
237
|
-
except Exception:
|
|
238
|
-
logger.debug("Failed to emit choice event", exc_info=True)
|
|
239
|
-
|
|
240
|
-
|
|
241
179
|
# ---------------------------------------------------------------------------
|
|
242
180
|
# Host-boundary span lifecycle
|
|
243
181
|
# ---------------------------------------------------------------------------
|
|
@@ -578,9 +516,15 @@ def stamp_provider_once(agent_span: Any, provider: Optional[str]) -> None:
|
|
|
578
516
|
|
|
579
517
|
|
|
580
518
|
def propagate_provider_to_parent(provider: Optional[str]) -> None:
|
|
581
|
-
"""Stamp the ambient ``_current_agent_span`` with the child call's provider
|
|
519
|
+
"""Stamp the ambient ``_current_agent_span`` with the child call's provider,
|
|
520
|
+
unless a framework span that stamps it itself owns this call (SDK-PY-9)."""
|
|
582
521
|
try:
|
|
583
|
-
from
|
|
522
|
+
from opentelemetry import context as otel_context
|
|
523
|
+
|
|
524
|
+
from struct_sdk.core import _PROVIDER_STAMP_OWNED_KEY, _current_agent_span
|
|
525
|
+
|
|
526
|
+
if otel_context.get_value(_PROVIDER_STAMP_OWNED_KEY):
|
|
527
|
+
return
|
|
584
528
|
stamp_provider_once(_current_agent_span.get(None), provider)
|
|
585
529
|
except Exception:
|
|
586
530
|
pass # Never break the application for telemetry
|