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.
@@ -118,3 +118,6 @@ struct-agent-key.json
118
118
 
119
119
  # Local/CI test result artifacts
120
120
  **/test-results/
121
+
122
+ # Generated agent session project ledgers (data, HTML and local helper state)
123
+ .project-ledgers/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: struct-sdk
3
- Version: 0.3.1
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 events from the Anthropic SDK, the OpenAI SDK
105
- (Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
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: 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). |
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`**: per-message content lands on OTel log
377
- records (`gen_ai.{user,assistant,system,tool}.message`,
378
- `gen_ai.choice`). Spans carry metadata only.
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 events from the Anthropic SDK, the OpenAI SDK
5
- (Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
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: 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). |
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`**: per-message content lands on OTel log
277
- records (`gen_ai.{user,assistant,system,tool}.message`,
278
- `gen_ai.choice`). Spans carry metadata only.
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.3.1"
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
- - The low-level per-message and choice LogRecord emitters
11
- (``emit_message_event`` / ``emit_choice_event``) — they take already-built
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
- """Truncate content fields within message parts (content/arguments/response)."""
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
- if "content" in part and isinstance(part["content"], str):
69
- part["content"] = truncate_field(part["content"])
70
- if "arguments" in part:
71
- arg = part["arguments"]
72
- if isinstance(arg, str):
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["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
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, applies
96
- a final hard cap that tries to keep the JSON array parseable.
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
- # If still too large after field-level truncation, do a final hard cap
114
- # but try to close the JSON array to keep it parseable.
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
- cut = result[:max_size - 50] # leave room for closing
117
- last_brace = cut.rfind("}")
118
- if last_brace > 0:
119
- result = cut[:last_brace + 1] + "]"
120
- else:
121
- result = "[]"
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 struct_sdk.core import _current_agent_span
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