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.
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/.gitignore +3 -0
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/PKG-INFO +10 -10
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/README.md +9 -9
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/pyproject.toml +1 -1
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/_genai_content.py +2 -114
- struct_sdk-0.4.2/src/struct_sdk/_operation_details.py +293 -0
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/anthropic.py +25 -161
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/core.py +6 -1
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/langchain.py +10 -158
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/openai.py +26 -127
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/pydantic_ai.py +94 -17
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/LICENSE +0 -0
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/_auto.py +0 -0
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/_distro.py +0 -0
- {struct_sdk-0.3.2 → struct_sdk-0.4.2}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.3.2 → 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
|
|
@@ -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
|
|
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:
|
|
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
|
|
231
|
-
|
|
232
|
-
arguments
|
|
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`**:
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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:
|
|
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
|
|
129
|
-
|
|
130
|
-
arguments
|
|
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`**:
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
@@ -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"
|