struct-sdk 0.3.2__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.2
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
@@ -103,7 +103,7 @@ Description-Content-Type: text/markdown
103
103
  # struct-sdk
104
104
 
105
105
  OpenTelemetry instrumentation for AI agents in Python. Captures spans,
106
- token usage, and message events from the Anthropic SDK, the OpenAI SDK
106
+ token usage, and message content from the Anthropic SDK, the OpenAI SDK
107
107
  (Responses API), pydantic-ai, the Claude Agent SDK, and LangChain /
108
108
  LangGraph, and exports them to [struct.ai](https://struct.ai) for observability.
109
109
 
@@ -164,7 +164,7 @@ async with struct.agent(name="checkout"):
164
164
  | Library | Span type | Notes |
165
165
  |---|---|---|
166
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). |
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
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
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
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. |
@@ -227,10 +227,9 @@ calls your code makes while reading a stream keep their spans). During agent
227
227
  runs, Struct guards telemetry faults and caps content. Realtime sessions and
228
228
  direct `InstrumentedModel` exception recording are outside this guarantee.
229
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.
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`.
234
233
  - **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
235
234
  around the run wins; otherwise pydantic-ai's `conversation_id` is used.
236
235
  Instrumentation configured on your own provider keeps its conversation IDs.
@@ -411,9 +410,10 @@ struct.init(
411
410
  )
412
411
  ```
413
412
 
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.
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.
417
417
  - **`SPAN_ONLY`**: content on span attributes (`gen_ai.input.messages`,
418
418
  `gen_ai.output.messages`).
419
419
  - **`SPAN_AND_EVENT`**: both.
@@ -1,7 +1,7 @@
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
4
+ token usage, and message content from the Anthropic SDK, the OpenAI SDK
5
5
  (Responses API), pydantic-ai, the Claude Agent SDK, and LangChain /
6
6
  LangGraph, and exports them to [struct.ai](https://struct.ai) for observability.
7
7
 
@@ -62,7 +62,7 @@ async with struct.agent(name="checkout"):
62
62
  | Library | Span type | Notes |
63
63
  |---|---|---|
64
64
  | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
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). |
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
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). |
67
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. |
68
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. |
@@ -125,10 +125,9 @@ calls your code makes while reading a stream keep their spans). During agent
125
125
  runs, Struct guards telemetry faults and caps content. Realtime sessions and
126
126
  direct `InstrumentedModel` exception recording are outside this guarantee.
127
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.
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`.
132
131
  - **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
133
132
  around the run wins; otherwise pydantic-ai's `conversation_id` is used.
134
133
  Instrumentation configured on your own provider keeps its conversation IDs.
@@ -309,9 +308,10 @@ struct.init(
309
308
  )
310
309
  ```
311
310
 
312
- - **`EVENT_ONLY`**: per-message content lands on OTel log
313
- records (`gen_ai.{user,assistant,system,tool}.message`,
314
- `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.
315
315
  - **`SPAN_ONLY`**: content on span attributes (`gen_ai.input.messages`,
316
316
  `gen_ai.output.messages`).
317
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.2"
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"
@@ -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
@@ -181,113 +176,6 @@ def safe_json(obj: Any) -> str:
181
176
  return "[]"
182
177
 
183
178
 
184
- # ---------------------------------------------------------------------------
185
- # LogRecord emission — OTel logs data model
186
- # ---------------------------------------------------------------------------
187
-
188
- def emit_message_event(
189
- otel_logger: Any,
190
- *,
191
- role: str,
192
- parts: list[dict[str, Any]],
193
- event_name: str,
194
- provider: str,
195
- message_index: int,
196
- span: Optional[trace.Span] = None,
197
- ) -> None:
198
- """Emit ONE per-message LogRecord linked to ``span``.
199
-
200
- OTel logs data-model convention:
201
- - ``body`` (log record body): the event tag (``gen_ai.user.message`` etc.).
202
- - ``attributes['body']``: the JSON payload ``{"role": ..., "parts": [...]}``.
203
- - Other attributes: ``event.name``, ``gen_ai.provider.name``,
204
- ``gen_ai.message.index``, and ``gen_ai.conversation.id`` when a session is
205
- active.
206
-
207
- Span-context is taken from the EXPLICIT ``span`` when given —
208
- ``trace.get_current_span()`` can drop the active span across the generator
209
- wrapper's async awaits. All work is best-effort: a failure here NEVER
210
- reaches the customer's call (it becomes a ``logger.debug``).
211
- """
212
- try:
213
- from opentelemetry._logs import LogRecord, SeverityNumber
214
-
215
- span_ctx = (span or trace.get_current_span()).get_span_context()
216
- from struct_sdk.core import _current_session_id
217
- session_id = _current_session_id.get(None)
218
-
219
- payload = json.dumps({"role": role, "parts": truncate_parts(parts)}, default=str)
220
- attrs: dict[str, Any] = {
221
- "event.name": event_name,
222
- "body": payload,
223
- "gen_ai.provider.name": provider,
224
- "gen_ai.message.index": message_index,
225
- }
226
- if session_id:
227
- attrs["gen_ai.conversation.id"] = session_id
228
-
229
- otel_logger.emit(LogRecord(
230
- timestamp=int(time.time_ns()),
231
- trace_id=span_ctx.trace_id,
232
- span_id=span_ctx.span_id,
233
- trace_flags=span_ctx.trace_flags,
234
- severity_number=SeverityNumber.INFO,
235
- body=event_name,
236
- attributes=attrs,
237
- ))
238
- except Exception:
239
- logger.debug("Failed to emit message event", exc_info=True)
240
-
241
-
242
- def emit_choice_event(
243
- otel_logger: Any,
244
- *,
245
- parts: list[dict[str, Any]],
246
- finish_reason: str,
247
- provider: str,
248
- span: Optional[trace.Span] = None,
249
- ) -> None:
250
- """Emit a ``gen_ai.choice`` LogRecord for the assistant's response.
251
-
252
- ``parts`` are already spec-shaped assistant parts and ``finish_reason`` is
253
- already mapped to the spec name by the caller (mapping is provider-specific).
254
- The ``choice`` event omits ``gen_ai.message.index``.
255
- """
256
- try:
257
- from opentelemetry._logs import LogRecord, SeverityNumber
258
-
259
- span_ctx = (span or trace.get_current_span()).get_span_context()
260
- from struct_sdk.core import _current_session_id
261
- session_id = _current_session_id.get(None)
262
-
263
- choice_body = {
264
- "index": 0,
265
- "finish_reason": finish_reason or "stop",
266
- "message": {"role": "assistant", "parts": truncate_parts(parts)},
267
- }
268
- payload = json.dumps(choice_body, default=str)
269
- event_name = "gen_ai.choice"
270
- attrs: dict[str, Any] = {
271
- "event.name": event_name,
272
- "body": payload,
273
- "gen_ai.provider.name": provider,
274
- }
275
- if session_id:
276
- attrs["gen_ai.conversation.id"] = session_id
277
-
278
- otel_logger.emit(LogRecord(
279
- timestamp=int(time.time_ns()),
280
- trace_id=span_ctx.trace_id,
281
- span_id=span_ctx.span_id,
282
- trace_flags=span_ctx.trace_flags,
283
- severity_number=SeverityNumber.INFO,
284
- body=event_name,
285
- attributes=attrs,
286
- ))
287
- except Exception:
288
- logger.debug("Failed to emit choice event", exc_info=True)
289
-
290
-
291
179
  # ---------------------------------------------------------------------------
292
180
  # Host-boundary span lifecycle
293
181
  # ---------------------------------------------------------------------------
@@ -0,0 +1,293 @@
1
+ """Message content as OTel's per-call ``gen_ai.client.inference.operation.details`` event.
2
+
3
+ Anthropic, OpenAI and LangChain build a model call's content once (``gen_ai.input.messages``,
4
+ ``gen_ai.output.messages``, ``gen_ai.system_instructions``,
5
+ ``gen_ai.tool.definitions``) and hand each attribute to :func:`record_content`.
6
+ The content mode decides where it goes (SDK-PY-11):
7
+
8
+ - span modes (``span_only``, ``span_and_event``): on the ``chat`` span, as a capped
9
+ JSON string, as before;
10
+ - event modes (``event_only``, ``span_and_event``): onto the ``chat`` span object
11
+ itself (a private attribute, never exported). When that span ends,
12
+ :class:`OperationDetailsProcessor` writes ONE
13
+ log record for the call (SDK-PY-45): linked to the span, severity DEBUG, with the
14
+ span's provider, models, response id, finish reasons, usage, ``error.type`` and
15
+ session, and the content as structured values (the conventions require structured
16
+ content on events).
17
+
18
+ This replaces the deprecated one-record-per-message events (``gen_ai.user.message``
19
+ ... ``gen_ai.choice``). pydantic-ai's content attributes reach the same place from
20
+ Struct's guarded tracer (SDK-PY-46). Nothing here may fault the host's call.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ import threading
29
+ import weakref
30
+ from typing import Any, Optional
31
+
32
+ from opentelemetry.sdk.trace import Span as SdkSpan
33
+ from opentelemetry.sdk.trace import SpanProcessor
34
+ from opentelemetry.trace import StatusCode
35
+
36
+ from struct_sdk._genai_content import truncate_and_serialize
37
+ from struct_sdk.core import _contain_logged
38
+
39
+ logger = logging.getLogger("struct_sdk._operation_details")
40
+
41
+ OPERATION_DETAILS_EVENT = "gen_ai.client.inference.operation.details"
42
+
43
+ CONTENT_KEYS = frozenset({
44
+ "gen_ai.input.messages",
45
+ "gen_ai.output.messages",
46
+ "gen_ai.system_instructions",
47
+ "gen_ai.tool.definitions",
48
+ })
49
+
50
+ # Span attributes copied onto the event (the convention's non-content attributes).
51
+ _SPAN_KEYS = (
52
+ "gen_ai.operation.name",
53
+ "gen_ai.provider.name",
54
+ "gen_ai.request.model",
55
+ "gen_ai.response.model",
56
+ "gen_ai.response.id",
57
+ "gen_ai.response.finish_reasons",
58
+ "gen_ai.conversation.id",
59
+ "gen_ai.request.max_tokens",
60
+ "gen_ai.request.temperature",
61
+ "gen_ai.request.top_p",
62
+ "gen_ai.request.top_k",
63
+ "gen_ai.request.stop_sequences",
64
+ "gen_ai.request.frequency_penalty",
65
+ "gen_ai.request.presence_penalty",
66
+ "gen_ai.output.type",
67
+ "server.address",
68
+ "server.port",
69
+ "error.type",
70
+ )
71
+ _USAGE_PREFIX = "gen_ai.usage."
72
+
73
+ # A call's content lives on its own span object until the span ends, under this
74
+ # private attribute (state about spans Struct creates lives on them; nothing global
75
+ # holds content). It goes when the span goes: no eviction, no fork or shutdown cleanup.
76
+ _CONTENT_ATTR = "_struct_operation_details"
77
+ _lock = threading.Lock() # buffering vs. the span's end, which may run on another thread
78
+
79
+
80
+ def _reset_after_fork() -> None:
81
+ """A forked child gets a fresh lock: one another thread held at fork time would
82
+ otherwise stay locked in the child and hang its next model call."""
83
+ global _lock
84
+ _lock = threading.Lock()
85
+
86
+
87
+ if hasattr(os, "register_at_fork"):
88
+ os.register_at_fork(after_in_child=_reset_after_fork)
89
+
90
+ # opentelemetry-sdk 1.40+ hands processors the live span at ``_on_ending``, before
91
+ # ``on_end`` gets a read-only copy. Older SDKs (the floor is 1.27) only call
92
+ # ``on_end``, so there a weak index from span id to the live span (spans only,
93
+ # never content) lets the processor find it.
94
+ def _log_record_has_event_name() -> bool:
95
+ import inspect
96
+
97
+ from opentelemetry._logs import LogRecord
98
+
99
+ try:
100
+ return "event_name" in inspect.signature(LogRecord.__init__).parameters
101
+ except (TypeError, ValueError):
102
+ return False
103
+
104
+
105
+ # opentelemetry-sdk releases before LogRecord took ``event_name`` export only body and attributes.
106
+ _LOG_RECORD_HAS_EVENT_NAME = _log_record_has_event_name()
107
+ _ENDING_HOOK = "_on_ending" in getattr(getattr(SdkSpan.end, "__code__", None), "co_names", ())
108
+ _live_spans: "weakref.WeakValueDictionary[tuple[int, int], Any]" = weakref.WeakValueDictionary()
109
+
110
+
111
+ def _span_key(span: Any) -> Optional[tuple[int, int]]:
112
+ context = span.get_span_context()
113
+ if not context or not context.is_valid:
114
+ return None
115
+ return (context.trace_id, context.span_id)
116
+
117
+
118
+ _INT64_MIN, _INT64_MAX = -(2**63), 2**63 - 1
119
+
120
+
121
+ def _encodable(value: Any) -> Any:
122
+ """``value`` with what an OTLP AnyValue cannot hold made lossless: an integer
123
+ outside int64 becomes its decimal string, a string with a lone surrogate its
124
+ JSON escape. Nulls stay."""
125
+ if isinstance(value, bool) or value is None or isinstance(value, float):
126
+ return value
127
+ if isinstance(value, int):
128
+ return value if _INT64_MIN <= value <= _INT64_MAX else str(value)
129
+ if isinstance(value, str):
130
+ try:
131
+ value.encode("utf-8")
132
+ except UnicodeEncodeError:
133
+ return json.dumps(value)[1:-1]
134
+ return value
135
+ if isinstance(value, list):
136
+ return [_encodable(item) for item in value]
137
+ if isinstance(value, dict):
138
+ # Escaping a key could merge it with a key already spelled that way: keep such
139
+ # an object whole, as escaped JSON text (the messages around it stay structured).
140
+ if any(isinstance(k, str) and _encodable(k) != k for k in value):
141
+ return json.dumps(value)
142
+ return {k if isinstance(k, str) else str(k): _encodable(v) for k, v in value.items()}
143
+ return value
144
+
145
+
146
+ def _structured(value: Any) -> Any:
147
+ """``value`` (a JSON string or a structure) as plain lists, dicts and scalars,
148
+ capped like span content (16,384 characters per message-part field or top-level
149
+ system content; 128 KiB per attribute, including tool definitions; valid JSON)
150
+ and encodable as OTLP AnyValues (``_encodable``). ``null`` values are kept: tool
151
+ arguments and schemas carry them, and the OTel log encoder accepts them."""
152
+ if isinstance(value, str):
153
+ value = json.loads(value)
154
+ structured = _encodable(json.loads(truncate_and_serialize(value)))
155
+ # Encoding can grow a value (a large integer becomes a quoted string): cap again.
156
+ return json.loads(truncate_and_serialize(structured))
157
+
158
+
159
+ def _sdk_span(span: Any) -> Any:
160
+ """The OTel SDK span under Struct's pydantic-ai guard, or ``span`` itself."""
161
+ return getattr(span, "_span", span)
162
+
163
+
164
+ def buffer_content(span: Any, key: str, value: Any) -> None:
165
+ """Keep ``value`` on the span for the call's ``operation.details`` event."""
166
+
167
+ def buffer() -> None:
168
+ target = _sdk_span(span)
169
+ # A sampled-out (non-recording) span never reaches a processor: nothing to keep.
170
+ if value is None or not target.is_recording():
171
+ return
172
+ structured = _structured(value)
173
+ with _lock:
174
+ content = getattr(target, _CONTENT_ATTR, None)
175
+ if content is None:
176
+ content = {}
177
+ setattr(target, _CONTENT_ATTR, content)
178
+ if not _ENDING_HOOK:
179
+ span_key = _span_key(target)
180
+ if span_key is not None:
181
+ _live_spans[span_key] = target
182
+ content[key] = structured
183
+
184
+ _contain_logged(buffer, site="operation_details.buffer")
185
+
186
+
187
+ def _take_content(live_span: Any) -> Optional[dict[str, Any]]:
188
+ with _lock:
189
+ content = getattr(live_span, _CONTENT_ATTR, None)
190
+ if content is not None:
191
+ setattr(live_span, _CONTENT_ATTR, None)
192
+ return content
193
+
194
+
195
+ def record_content(sdk: Any, span: Any, key: str, value: Any) -> None:
196
+ """Route one content attribute of a model call by the content mode (SDK-PY-11).
197
+
198
+ ``value`` is the capped JSON string the span would carry, or the structure."""
199
+ if value is None:
200
+ return
201
+ if sdk.emit_span_content:
202
+ _contain_logged(
203
+ lambda: span.set_attribute(key, value if isinstance(value, str) else truncate_and_serialize(value)),
204
+ site="operation_details.span_content",
205
+ )
206
+ if sdk.emit_events:
207
+ buffer_content(span, key, value)
208
+
209
+
210
+ class OperationDetailsProcessor(SpanProcessor):
211
+ """Writes a call's ``operation.details`` event when its span ends (SDK-PY-45).
212
+
213
+ Only spans carrying content produce an event; content is kept only in event
214
+ modes. Runs inside the OTel SDK's span end, so nothing may escape."""
215
+
216
+ def __init__(self, sdk: Any) -> None:
217
+ self._sdk = sdk
218
+
219
+ def on_start(self, span: Any, parent_context: Any = None) -> None:
220
+ pass
221
+
222
+ def _on_ending(self, span: Any) -> None:
223
+ # The live span, ended (end time and status final), before on_end's copy.
224
+ if _ENDING_HOOK:
225
+ _contain_logged(lambda: self._emit(span, _take_content(span)), site="operation_details.emit")
226
+
227
+ def on_end(self, span: Any) -> None:
228
+ if _ENDING_HOOK:
229
+ return
230
+
231
+ def emit() -> None:
232
+ span_key = _span_key(span)
233
+ live = _live_spans.pop(span_key, None) if span_key is not None else None
234
+ if live is not None:
235
+ self._emit(span, _take_content(live))
236
+
237
+ _contain_logged(emit, site="operation_details.emit")
238
+
239
+ def _emit(self, span: Any, content: Optional[dict[str, Any]]) -> None:
240
+ # A record needs the call's messages: tool definitions alone (a failed
241
+ # pydantic-ai request, whose messages pydantic-ai never wrote) say nothing.
242
+ if not content or not self._sdk.emit_events or not (
243
+ "gen_ai.input.messages" in content or "gen_ai.output.messages" in content
244
+ ):
245
+ return
246
+ from opentelemetry._logs import LogRecord, SeverityNumber
247
+
248
+ span_attributes = dict(span.attributes or {})
249
+ attributes: dict[str, Any] = {"event.name": OPERATION_DETAILS_EVENT}
250
+ for key in _SPAN_KEYS:
251
+ if key in span_attributes:
252
+ value = span_attributes[key]
253
+ attributes[key] = list(value) if isinstance(value, tuple) else value
254
+ for key, value in span_attributes.items():
255
+ if key.startswith(_USAGE_PREFIX):
256
+ attributes[key] = value
257
+ attributes.setdefault("gen_ai.operation.name", "chat")
258
+ status = getattr(span, "status", None)
259
+ if status is not None and status.status_code == StatusCode.ERROR:
260
+ attributes.setdefault("error.type", _error_type(span, status))
261
+ attributes.update(content)
262
+
263
+ context = span.get_span_context()
264
+ record: dict[str, Any] = {
265
+ "timestamp": span.end_time,
266
+ "trace_id": context.trace_id,
267
+ "span_id": context.span_id,
268
+ "trace_flags": context.trace_flags,
269
+ "severity_text": "DEBUG",
270
+ "severity_number": SeverityNumber.DEBUG,
271
+ "body": OPERATION_DETAILS_EVENT, # Struct's readers match the body
272
+ "attributes": attributes,
273
+ }
274
+ if _LOG_RECORD_HAS_EVENT_NAME: # the OTLP event name the conventions require
275
+ record["event_name"] = OPERATION_DETAILS_EVENT
276
+ self._sdk.get_logger("struct-sdk").emit(LogRecord(**record))
277
+
278
+ def shutdown(self) -> None:
279
+ pass
280
+
281
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
282
+ return True
283
+
284
+
285
+ def _error_type(span: Any, status: Any) -> str:
286
+ """The exception type recorded on the span, else ``_OTHER`` (the convention's
287
+ fallback when the error class is unknown)."""
288
+ for event in reversed(getattr(span, "events", None) or ()):
289
+ if event.name == "exception":
290
+ error_type = (event.attributes or {}).get("exception.type")
291
+ if error_type:
292
+ return str(error_type)
293
+ return "_OTHER"