struct-sdk 0.2.15__tar.gz → 0.2.22__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.2.15 → struct_sdk-0.2.22}/PKG-INFO +3 -2
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/README.md +1 -1
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/pyproject.toml +8 -2
- struct_sdk-0.2.22/src/struct_sdk/_genai_content.py +580 -0
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/src/struct_sdk/anthropic.py +262 -103
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/src/struct_sdk/core.py +105 -8
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/src/struct_sdk/langchain.py +73 -10
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/src/struct_sdk/openai.py +98 -81
- struct_sdk-0.2.15/src/struct_sdk/_genai_content.py +0 -265
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/.gitignore +0 -0
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/LICENSE +0 -0
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.2.15 → struct_sdk-0.2.22}/src/struct_sdk/claude_agent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: struct-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.22
|
|
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
|
|
@@ -28,6 +28,7 @@ Provides-Extra: anthropic
|
|
|
28
28
|
Requires-Dist: anthropic>=0.30.0; extra == 'anthropic'
|
|
29
29
|
Provides-Extra: claude-agent-sdk
|
|
30
30
|
Requires-Dist: claude-agent-sdk>=0.1.59; extra == 'claude-agent-sdk'
|
|
31
|
+
Requires-Dist: mcp>=1.28.1; extra == 'claude-agent-sdk'
|
|
31
32
|
Provides-Extra: demo
|
|
32
33
|
Requires-Dist: langchain-anthropic>=1.4.6; extra == 'demo'
|
|
33
34
|
Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
|
|
@@ -380,7 +381,7 @@ backlink still renders.
|
|
|
380
381
|
Emits attributes per the OTel GenAI semantic conventions:
|
|
381
382
|
|
|
382
383
|
- `gen_ai.operation.name` — `chat`, `execute_tool`, `invoke_agent`, `retrieval`
|
|
383
|
-
- `gen_ai.provider.name` — `anthropic`, `openai`, `
|
|
384
|
+
- `gen_ai.provider.name` — the GenAI provider on inference spans (`anthropic`, `openai`, …); platform-routed calls report the platform value (`aws.bedrock`, `gcp.vertex_ai`, `azure.ai.openai`) when detectable from the client. `invoke_agent` spans inherit the provider from their first child inference call (omitted when no model is reached); `execute_tool`/`retrieval` spans carry no provider.
|
|
384
385
|
- `gen_ai.request.{model, max_tokens, temperature, top_p, top_k, stop_sequences}`
|
|
385
386
|
- `gen_ai.response.{model, id, finish_reasons}`
|
|
386
387
|
- `gen_ai.usage.{input_tokens, output_tokens, cache_read.input_tokens, cache_creation.input_tokens}`
|
|
@@ -332,7 +332,7 @@ backlink still renders.
|
|
|
332
332
|
Emits attributes per the OTel GenAI semantic conventions:
|
|
333
333
|
|
|
334
334
|
- `gen_ai.operation.name` — `chat`, `execute_tool`, `invoke_agent`, `retrieval`
|
|
335
|
-
- `gen_ai.provider.name` — `anthropic`, `openai`, `
|
|
335
|
+
- `gen_ai.provider.name` — the GenAI provider on inference spans (`anthropic`, `openai`, …); platform-routed calls report the platform value (`aws.bedrock`, `gcp.vertex_ai`, `azure.ai.openai`) when detectable from the client. `invoke_agent` spans inherit the provider from their first child inference call (omitted when no model is reached); `execute_tool`/`retrieval` spans carry no provider.
|
|
336
336
|
- `gen_ai.request.{model, max_tokens, temperature, top_p, top_k, stop_sequences}`
|
|
337
337
|
- `gen_ai.response.{model, id, finish_reasons}`
|
|
338
338
|
- `gen_ai.usage.{input_tokens, output_tokens, cache_read.input_tokens, cache_creation.input_tokens}`
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "struct-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.22"
|
|
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"
|
|
@@ -37,7 +37,11 @@ Issues = "https://struct.ai/support"
|
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
39
|
anthropic = ["anthropic>=0.30.0"]
|
|
40
|
-
|
|
40
|
+
# mcp>=1.28.1 pinned directly (not just in override-dependencies below) so it
|
|
41
|
+
# ships in this extra's published wheel metadata — CVE-2026-52870,
|
|
42
|
+
# CVE-2026-52869, CVE-2026-59950 (GHSA-hvrp-rf83-w775, GHSA-jpw9-pfvf-9f58,
|
|
43
|
+
# GHSA-vj7q-gjh5-988w).
|
|
44
|
+
claude-agent-sdk = ["claude-agent-sdk>=0.1.59", "mcp>=1.28.1"]
|
|
41
45
|
langchain = ["langchain-core>=1.3.3"]
|
|
42
46
|
# Responses API (openai.resources.responses) exists from 1.66.0 onward.
|
|
43
47
|
openai = ["openai>=1.66.0"]
|
|
@@ -80,6 +84,8 @@ override-dependencies = [
|
|
|
80
84
|
"starlette>=1.3.1",
|
|
81
85
|
"langsmith>=0.8.18",
|
|
82
86
|
"pydantic-settings>=2.14.2",
|
|
87
|
+
# CVE-2026-52870, CVE-2026-52869, CVE-2026-59950 (GHSA-hvrp-rf83-w775, GHSA-jpw9-pfvf-9f58, GHSA-vj7q-gjh5-988w)
|
|
88
|
+
"mcp>=1.28.1",
|
|
83
89
|
]
|
|
84
90
|
|
|
85
91
|
[tool.pytest.ini_options]
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""Provider-agnostic GenAI content primitives — shared by the anthropic and
|
|
2
|
+
openai integrations.
|
|
3
|
+
|
|
4
|
+
This module is the single home for the pieces that are genuinely the same
|
|
5
|
+
across providers once a message/response has been mapped to the spec's
|
|
6
|
+
``{"role", "parts"}`` shape:
|
|
7
|
+
|
|
8
|
+
- Truncation constants + helpers (``truncate_field`` / ``truncate_parts`` /
|
|
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.
|
|
15
|
+
- ``propagate_user_prompt_to_parent`` — write-once parent ``gen_ai.input.messages``
|
|
16
|
+
from the last user message's parts.
|
|
17
|
+
|
|
18
|
+
The per-provider MAPPER (Anthropic message blocks vs OpenAI Responses items ->
|
|
19
|
+
``parts``) is the only content code that stays provider-specific; it lives in
|
|
20
|
+
``anthropic.py`` / ``openai.py`` and calls into this module.
|
|
21
|
+
|
|
22
|
+
Both this bug (OpenAI emitting zero content) and the earlier reset bug came from
|
|
23
|
+
``openai.py`` diverging from ``anthropic.py``; sharing these primitives is the
|
|
24
|
+
structural fix. Everything here is written to NEVER fault the customer call —
|
|
25
|
+
each public emitter is wrapped in try/except -> ``logger.debug``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from typing import Any, Callable, Iterator, Optional
|
|
36
|
+
from urllib.parse import urlparse
|
|
37
|
+
|
|
38
|
+
from opentelemetry import trace
|
|
39
|
+
from opentelemetry.trace import StatusCode
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("struct_sdk._genai_content")
|
|
42
|
+
|
|
43
|
+
# Max size for content attributes (JSON strings on spans).
|
|
44
|
+
_MAX_CONTENT_SIZE = 128 * 1024 # 128KB — generous limit for full message capture
|
|
45
|
+
_TRUNCATION_MARKER = "… [truncated]"
|
|
46
|
+
# Per-field cap: individual text/content fields longer than this get truncated.
|
|
47
|
+
_MAX_FIELD_SIZE = 16384 # 16KB per field
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Truncation / serialization
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def truncate_field(value: "str | Any", max_len: int = _MAX_FIELD_SIZE) -> "str | Any":
|
|
55
|
+
"""Truncate a single string field if it exceeds ``max_len``."""
|
|
56
|
+
if isinstance(value, str) and len(value) > max_len:
|
|
57
|
+
return value[:max_len] + _TRUNCATION_MARKER
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
62
|
+
"""Truncate content fields within message parts (content/arguments/response)."""
|
|
63
|
+
result = []
|
|
64
|
+
for part in parts:
|
|
65
|
+
part = dict(part) # shallow copy
|
|
66
|
+
if "content" in part and isinstance(part["content"], str):
|
|
67
|
+
part["content"] = truncate_field(part["content"])
|
|
68
|
+
if "arguments" in part:
|
|
69
|
+
arg = part["arguments"]
|
|
70
|
+
if isinstance(arg, str):
|
|
71
|
+
part["arguments"] = truncate_field(arg)
|
|
72
|
+
elif isinstance(arg, dict):
|
|
73
|
+
serialized = json.dumps(arg, default=str)
|
|
74
|
+
if len(serialized) > _MAX_FIELD_SIZE:
|
|
75
|
+
part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
76
|
+
if "response" in part:
|
|
77
|
+
resp = part["response"]
|
|
78
|
+
if isinstance(resp, str):
|
|
79
|
+
part["response"] = truncate_field(resp)
|
|
80
|
+
elif isinstance(resp, (dict, list)):
|
|
81
|
+
serialized = json.dumps(resp, default=str)
|
|
82
|
+
if len(serialized) > _MAX_FIELD_SIZE:
|
|
83
|
+
part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
84
|
+
result.append(part)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
|
|
89
|
+
"""Truncate individual fields in message structures, then serialize.
|
|
90
|
+
|
|
91
|
+
Unlike a blind string slice, this preserves valid JSON by truncating
|
|
92
|
+
content/arguments/response fields within parts before serialization.
|
|
93
|
+
If the result still exceeds ``max_size`` after field truncation, applies
|
|
94
|
+
a final hard cap that tries to keep the JSON array parseable.
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(obj, list):
|
|
97
|
+
truncated = []
|
|
98
|
+
for item in obj:
|
|
99
|
+
if isinstance(item, dict):
|
|
100
|
+
item = dict(item) # shallow copy
|
|
101
|
+
if "parts" in item and isinstance(item["parts"], list):
|
|
102
|
+
item["parts"] = truncate_parts(item["parts"])
|
|
103
|
+
elif "content" in item and isinstance(item["content"], str):
|
|
104
|
+
# Top-level content (e.g. system_instructions format)
|
|
105
|
+
item["content"] = truncate_field(item["content"])
|
|
106
|
+
truncated.append(item)
|
|
107
|
+
result = json.dumps(truncated, default=str)
|
|
108
|
+
else:
|
|
109
|
+
result = json.dumps(obj, default=str)
|
|
110
|
+
|
|
111
|
+
# If still too large after field-level truncation, do a final hard cap
|
|
112
|
+
# but try to close the JSON array to keep it parseable.
|
|
113
|
+
if len(result) > max_size:
|
|
114
|
+
cut = result[:max_size - 50] # leave room for closing
|
|
115
|
+
last_brace = cut.rfind("}")
|
|
116
|
+
if last_brace > 0:
|
|
117
|
+
result = cut[:last_brace + 1] + "]"
|
|
118
|
+
else:
|
|
119
|
+
result = "[]"
|
|
120
|
+
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def safe_json(obj: Any) -> str:
|
|
125
|
+
"""Safely serialize to JSON string with field-level truncation."""
|
|
126
|
+
try:
|
|
127
|
+
return truncate_and_serialize(obj)
|
|
128
|
+
except Exception:
|
|
129
|
+
return "[]"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# LogRecord emission — OTel logs data model
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def emit_message_event(
|
|
137
|
+
otel_logger: Any,
|
|
138
|
+
*,
|
|
139
|
+
role: str,
|
|
140
|
+
parts: list[dict[str, Any]],
|
|
141
|
+
event_name: str,
|
|
142
|
+
provider: str,
|
|
143
|
+
message_index: int,
|
|
144
|
+
span: Optional[trace.Span] = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Emit ONE per-message LogRecord linked to ``span``.
|
|
147
|
+
|
|
148
|
+
OTel logs data-model convention:
|
|
149
|
+
- ``body`` (log record body): the event tag (``gen_ai.user.message`` etc.).
|
|
150
|
+
- ``attributes['body']``: the JSON payload ``{"role": ..., "parts": [...]}``.
|
|
151
|
+
- Other attributes: ``event.name``, ``gen_ai.provider.name``,
|
|
152
|
+
``gen_ai.message.index``, and ``gen_ai.conversation.id`` when a session is
|
|
153
|
+
active.
|
|
154
|
+
|
|
155
|
+
Span-context is taken from the EXPLICIT ``span`` when given —
|
|
156
|
+
``trace.get_current_span()`` can drop the active span across the generator
|
|
157
|
+
wrapper's async awaits. All work is best-effort: a failure here NEVER
|
|
158
|
+
reaches the customer's call (it becomes a ``logger.debug``).
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
162
|
+
|
|
163
|
+
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
164
|
+
from struct_sdk.core import _current_session_id
|
|
165
|
+
session_id = _current_session_id.get(None)
|
|
166
|
+
|
|
167
|
+
payload = json.dumps({"role": role, "parts": truncate_parts(parts)}, default=str)
|
|
168
|
+
attrs: dict[str, Any] = {
|
|
169
|
+
"event.name": event_name,
|
|
170
|
+
"body": payload,
|
|
171
|
+
"gen_ai.provider.name": provider,
|
|
172
|
+
"gen_ai.message.index": message_index,
|
|
173
|
+
}
|
|
174
|
+
if session_id:
|
|
175
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
176
|
+
|
|
177
|
+
otel_logger.emit(LogRecord(
|
|
178
|
+
timestamp=int(time.time_ns()),
|
|
179
|
+
trace_id=span_ctx.trace_id,
|
|
180
|
+
span_id=span_ctx.span_id,
|
|
181
|
+
trace_flags=span_ctx.trace_flags,
|
|
182
|
+
severity_number=SeverityNumber.INFO,
|
|
183
|
+
body=event_name,
|
|
184
|
+
attributes=attrs,
|
|
185
|
+
))
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.debug("Failed to emit message event", exc_info=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def emit_choice_event(
|
|
191
|
+
otel_logger: Any,
|
|
192
|
+
*,
|
|
193
|
+
parts: list[dict[str, Any]],
|
|
194
|
+
finish_reason: str,
|
|
195
|
+
provider: str,
|
|
196
|
+
span: Optional[trace.Span] = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Emit a ``gen_ai.choice`` LogRecord for the assistant's response.
|
|
199
|
+
|
|
200
|
+
``parts`` are already spec-shaped assistant parts and ``finish_reason`` is
|
|
201
|
+
already mapped to the spec name by the caller (mapping is provider-specific).
|
|
202
|
+
The ``choice`` event omits ``gen_ai.message.index``.
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
206
|
+
|
|
207
|
+
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
208
|
+
from struct_sdk.core import _current_session_id
|
|
209
|
+
session_id = _current_session_id.get(None)
|
|
210
|
+
|
|
211
|
+
choice_body = {
|
|
212
|
+
"index": 0,
|
|
213
|
+
"finish_reason": finish_reason or "stop",
|
|
214
|
+
"message": {"role": "assistant", "parts": truncate_parts(parts)},
|
|
215
|
+
}
|
|
216
|
+
payload = json.dumps(choice_body, default=str)
|
|
217
|
+
event_name = "gen_ai.choice"
|
|
218
|
+
attrs: dict[str, Any] = {
|
|
219
|
+
"event.name": event_name,
|
|
220
|
+
"body": payload,
|
|
221
|
+
"gen_ai.provider.name": provider,
|
|
222
|
+
}
|
|
223
|
+
if session_id:
|
|
224
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
225
|
+
|
|
226
|
+
otel_logger.emit(LogRecord(
|
|
227
|
+
timestamp=int(time.time_ns()),
|
|
228
|
+
trace_id=span_ctx.trace_id,
|
|
229
|
+
span_id=span_ctx.span_id,
|
|
230
|
+
trace_flags=span_ctx.trace_flags,
|
|
231
|
+
severity_number=SeverityNumber.INFO,
|
|
232
|
+
body=event_name,
|
|
233
|
+
attributes=attrs,
|
|
234
|
+
))
|
|
235
|
+
except Exception:
|
|
236
|
+
logger.debug("Failed to emit choice event", exc_info=True)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Host-boundary span lifecycle
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _contain(fn: Any, site: str) -> None:
|
|
244
|
+
"""``_safe`` with BASE-exception containment, for satellites that run
|
|
245
|
+
directly ON THE HOST CALL PATH only: guarded_client_span's start/attach/
|
|
246
|
+
finalize/end, and the create-path error satellites that stringify the
|
|
247
|
+
host's exception inside ``gen.throw``. There, a host-installed
|
|
248
|
+
SpanProcessor (or a host exception's ``__str__``) raising
|
|
249
|
+
KeyboardInterrupt/SystemExit would otherwise replace the provider's
|
|
250
|
+
successful result or in-flight exception — the one thing the harness
|
|
251
|
+
guarantees cannot happen. Deliberately NOT the global ``_safe``
|
|
252
|
+
semantics: everywhere else, delaying a genuine Ctrl-C matters more than
|
|
253
|
+
a hostile input.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
fn()
|
|
257
|
+
except BaseException: # noqa: BLE001 — containment is the contract here
|
|
258
|
+
try:
|
|
259
|
+
logger.debug("telemetry self-fault contained at %s", site, exc_info=True)
|
|
260
|
+
except BaseException: # noqa: BLE001
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@contextlib.contextmanager
|
|
265
|
+
def guarded_client_span(tracer: Any, span_name: str) -> Iterator[Any]:
|
|
266
|
+
"""Explicit CLIENT span with ``_safe``-guarded start and end.
|
|
267
|
+
|
|
268
|
+
Deliberately NOT ``start_as_current_span``: ``use_span``'s exit runs
|
|
269
|
+
``span.end()`` — and with it every customer-added ``SpanProcessor.on_end``
|
|
270
|
+
— plus its own ``record_exception``/``set_status`` (which stringify the
|
|
271
|
+
host's exception) UNGUARDED, and in the create-path generator sandwich
|
|
272
|
+
that exit executes on the ``gen.send``/``gen.throw`` HOST path. A broken
|
|
273
|
+
processor or a host exception with a raising ``__str__`` would replace
|
|
274
|
+
the customer's result/exception (host-crash-on-success).
|
|
275
|
+
|
|
276
|
+
This CM starts the span under ``_safe`` (yields ``None`` on failure — the
|
|
277
|
+
caller's ``_safe`` satellites degrade to no-telemetry) and ends it under
|
|
278
|
+
``_safe`` on every exit. Ambient propagation is GUARDED, not removed: the
|
|
279
|
+
span is attached to the current context via a ``_safe`` satellite so a
|
|
280
|
+
customer's own downstream instrumentation (their HTTP-layer spans fired
|
|
281
|
+
during the provider call) still nests under ``chat`` — removing
|
|
282
|
+
propagation outright was struct-sdk-typescript's round-2 regression
|
|
283
|
+
(#3280, 343de609d). A hostile runtime context degrades to no-ambient
|
|
284
|
+
(span still emitted; event linkage rides the explicit span); OTel's
|
|
285
|
+
``context.detach`` swallows internally and is ``_safe``-wrapped besides.
|
|
286
|
+
Nothing host-controllable wraps the caller's block unguarded. Mirrors
|
|
287
|
+
struct-sdk-typescript's ``instrumentCall``.
|
|
288
|
+
"""
|
|
289
|
+
from opentelemetry import trace as _trace
|
|
290
|
+
|
|
291
|
+
from struct_sdk.core import (
|
|
292
|
+
_attach_current_span,
|
|
293
|
+
_detach_current_span,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
span: Any = None
|
|
297
|
+
token: Any = None
|
|
298
|
+
enter_task: Any = None
|
|
299
|
+
|
|
300
|
+
def start() -> None:
|
|
301
|
+
nonlocal span
|
|
302
|
+
span = tracer.start_span(span_name, kind=_trace.SpanKind.CLIENT)
|
|
303
|
+
|
|
304
|
+
# DOCUMENTED DEGRADE: if a host-installed SpanProcessor raises from
|
|
305
|
+
# on_start, the OTel SDK lets it propagate out of start_span WITHOUT
|
|
306
|
+
# guarding (SynchronousMultiSpanProcessor.on_start is a bare loop) and
|
|
307
|
+
# the just-created span's reference never escapes the SDK frame — it is
|
|
308
|
+
# unrecoverable at this layer, and earlier processors see an on_start
|
|
309
|
+
# with no on_end. Host survival takes precedence (the OTel spec requires
|
|
310
|
+
# processors not to throw); recovering would need a global span registry,
|
|
311
|
+
# which AGENTS.md §7 forbids for exactly this kind of unreachable-in-
|
|
312
|
+
# practice input. The same degrade existed for plain Exception under
|
|
313
|
+
# ``_safe`` since this CM's inception.
|
|
314
|
+
_contain(start, "genai.client_span.start")
|
|
315
|
+
|
|
316
|
+
if span is not None:
|
|
317
|
+
def attach_ambient() -> None:
|
|
318
|
+
# Reuse the SDK's ONE ambient-context lifecycle (task-aware pair
|
|
319
|
+
# from core): a cross-task exit must SKIP the detach — OTel logs
|
|
320
|
+
# "Failed to detach context" at ERROR internally, before _safe
|
|
321
|
+
# could suppress it. No second lifecycle to drift.
|
|
322
|
+
nonlocal token, enter_task
|
|
323
|
+
token, enter_task = _attach_current_span(span)
|
|
324
|
+
|
|
325
|
+
_contain(attach_ambient, "genai.client_span.attach")
|
|
326
|
+
try:
|
|
327
|
+
yield span
|
|
328
|
+
finally:
|
|
329
|
+
if span is not None:
|
|
330
|
+
def finalize_base_exception() -> None:
|
|
331
|
+
# The caller's except-blocks record ordinary Exceptions; a
|
|
332
|
+
# BaseException (CancelledError, KeyboardInterrupt) bypasses
|
|
333
|
+
# them, so record it here — status + error.type only, message
|
|
334
|
+
# is the type name (never str(exc): hostile __str__).
|
|
335
|
+
# GeneratorExit is normal generator teardown, not a failure.
|
|
336
|
+
exc = sys.exc_info()[1]
|
|
337
|
+
if (
|
|
338
|
+
exc is not None
|
|
339
|
+
and not isinstance(exc, Exception)
|
|
340
|
+
and not isinstance(exc, GeneratorExit)
|
|
341
|
+
):
|
|
342
|
+
span.set_attribute("error.type", type(exc).__name__)
|
|
343
|
+
span.set_status(StatusCode.ERROR, type(exc).__name__)
|
|
344
|
+
|
|
345
|
+
_contain(finalize_base_exception, "genai.client_span.finalize_base")
|
|
346
|
+
_contain(lambda: _detach_current_span(token, enter_task), "genai.client_span.detach")
|
|
347
|
+
if span is not None:
|
|
348
|
+
_contain(span.end, "genai.client_span.end")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# Generator-sandwich drivers — the host-boundary CHOKE POINT
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
#
|
|
355
|
+
# Provider modules express telemetry as a generator that yields exactly once:
|
|
356
|
+
# ``f, call_args, call_kwargs = yield`` marks the host call; code before the
|
|
357
|
+
# yield is setup, the except-block is error telemetry, code after is success
|
|
358
|
+
# telemetry. These two drivers own EVERY interaction with such a generator
|
|
359
|
+
# (prime / error-forward / complete) under BaseException containment, so the
|
|
360
|
+
# host-crash-on-success invariant holds structurally: a telemetry fault at
|
|
361
|
+
# ANY point degrades telemetry, never the host call. Per-site ``_safe`` /
|
|
362
|
+
# ``_contain`` calls inside the generators improve degradation quality (a
|
|
363
|
+
# broken satellite loses one attribute, not the span); they are no longer
|
|
364
|
+
# what stands between a hostile input and the customer's result. Mirrors
|
|
365
|
+
# struct-sdk-typescript's single ``instrumentCall`` combinator (and OTel
|
|
366
|
+
# contrib's ``safeExecuteInTheMiddle``).
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _log_contained(site: str) -> None:
|
|
370
|
+
try:
|
|
371
|
+
logger.debug("telemetry self-fault contained at %s", site, exc_info=True)
|
|
372
|
+
except BaseException: # noqa: BLE001 — containment is the contract here
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _prime_sandwich(make_gen: Any, site: str) -> Any:
|
|
377
|
+
"""Create + advance the telemetry generator to its yield, contained.
|
|
378
|
+
|
|
379
|
+
Returns ``(gen, f, call_args, call_kwargs)`` or ``None`` on any fault —
|
|
380
|
+
including a hostile yielded shape — so the caller can degrade to an
|
|
381
|
+
uninstrumented host call.
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
gen = make_gen()
|
|
385
|
+
f, call_args, call_kwargs = next(gen)
|
|
386
|
+
return gen, f, call_args, call_kwargs
|
|
387
|
+
except BaseException: # noqa: BLE001
|
|
388
|
+
_log_contained(site + ".prime")
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _forward_error(gen: Any, exc: BaseException, site: str) -> Any:
|
|
393
|
+
"""Drive the generator's error path, contained. The host's original
|
|
394
|
+
exception must keep propagating AS ITSELF even if the generator's error
|
|
395
|
+
telemetry raises something else. Returns a ``StopIteration`` value only
|
|
396
|
+
if the generator swallowed the error and returned one."""
|
|
397
|
+
try:
|
|
398
|
+
gen.throw(exc)
|
|
399
|
+
except StopIteration as e:
|
|
400
|
+
return e.value
|
|
401
|
+
except BaseException as out:
|
|
402
|
+
if out is exc:
|
|
403
|
+
raise
|
|
404
|
+
_log_contained(site + ".error_telemetry")
|
|
405
|
+
raise exc from None
|
|
406
|
+
# Generator yielded again instead of re-raising — out of contract;
|
|
407
|
+
# surface the host's error regardless.
|
|
408
|
+
raise exc
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _complete_sandwich(gen: Any, result: Any, site: str) -> Any:
|
|
412
|
+
"""Drive the generator's success path, contained. After the host call
|
|
413
|
+
SUCCEEDS, nothing raised during telemetry completion may replace the
|
|
414
|
+
result (a lazy response property can raise even ``KeyboardInterrupt``
|
|
415
|
+
during extraction)."""
|
|
416
|
+
try:
|
|
417
|
+
return gen.send(result)
|
|
418
|
+
except StopIteration as e:
|
|
419
|
+
return e.value
|
|
420
|
+
except BaseException: # noqa: BLE001 — telemetry-only from here
|
|
421
|
+
_log_contained(site + ".completion")
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def drive_instrumented_call(
|
|
426
|
+
make_gen: Any, original: Any, args: Any, kwargs: Any, site: str
|
|
427
|
+
) -> Any:
|
|
428
|
+
"""SYNC choke point: telemetry generator sandwich around ``original``."""
|
|
429
|
+
primed = _prime_sandwich(make_gen, site)
|
|
430
|
+
if primed is None:
|
|
431
|
+
return original(*args, **kwargs)
|
|
432
|
+
gen, f, call_args, call_kwargs = primed
|
|
433
|
+
try:
|
|
434
|
+
result = f(*call_args, **call_kwargs)
|
|
435
|
+
except BaseException as exc: # host error — forward into the generator
|
|
436
|
+
return _forward_error(gen, exc, site)
|
|
437
|
+
return _complete_sandwich(gen, result, site)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def drive_instrumented_call_async(
|
|
441
|
+
make_gen: Any, original: Any, args: Any, kwargs: Any, site: str
|
|
442
|
+
) -> Any:
|
|
443
|
+
"""ASYNC choke point — identical semantics; only the host call awaits."""
|
|
444
|
+
primed = _prime_sandwich(make_gen, site)
|
|
445
|
+
if primed is None:
|
|
446
|
+
return await original(*args, **kwargs)
|
|
447
|
+
gen, f, call_args, call_kwargs = primed
|
|
448
|
+
try:
|
|
449
|
+
result = await f(*call_args, **call_kwargs)
|
|
450
|
+
except BaseException as exc: # host error — forward into the generator
|
|
451
|
+
return _forward_error(gen, exc, site)
|
|
452
|
+
return _complete_sandwich(gen, result, site)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# Platform detection — shared by the anthropic/openai emitters
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def detect_provider_from_resource(
|
|
460
|
+
resource: Any,
|
|
461
|
+
class_name_rules: tuple[tuple[frozenset[str], str], ...],
|
|
462
|
+
host_rules: tuple[tuple[Callable[[str], bool], str], ...],
|
|
463
|
+
default: str,
|
|
464
|
+
) -> str:
|
|
465
|
+
"""Best-knowledge ``gen_ai.provider.name`` from a bound resource.
|
|
466
|
+
|
|
467
|
+
The platform client flavors (Bedrock/Vertex/Azure) reuse the exact same
|
|
468
|
+
resource classes as the first-party clients, so the platform can only be
|
|
469
|
+
read at call time from the resource's owning client: EXACT class names in
|
|
470
|
+
the MRO first (exact, not substring — a customer class named
|
|
471
|
+
``NotAzureOpenAI`` must not match; real subclasses still match via their
|
|
472
|
+
inherited base class in the MRO), then per-platform ``base_url`` host
|
|
473
|
+
predicates matching only official endpoint shapes.
|
|
474
|
+
|
|
475
|
+
Takes the RESOURCE, not the client: the ``_client`` attribute access is a
|
|
476
|
+
host-boundary read (a proxied resource can raise from its descriptor —
|
|
477
|
+
``getattr(..., default)`` only swallows AttributeError), so it must happen
|
|
478
|
+
inside this function's guard. Falls back to ``default`` whenever routing
|
|
479
|
+
is not positively detectable; never raises.
|
|
480
|
+
"""
|
|
481
|
+
try:
|
|
482
|
+
client = getattr(resource, "_client", None)
|
|
483
|
+
if client is None:
|
|
484
|
+
return default
|
|
485
|
+
mro_names = {cls.__name__ for cls in type(client).__mro__}
|
|
486
|
+
for names, provider in class_name_rules:
|
|
487
|
+
if mro_names & names:
|
|
488
|
+
return provider
|
|
489
|
+
host = urlparse(str(getattr(client, "base_url", "") or "")).hostname or ""
|
|
490
|
+
for matches_host, provider in host_rules:
|
|
491
|
+
if matches_host(host):
|
|
492
|
+
return provider
|
|
493
|
+
except Exception: # noqa: BLE001 — detection must never fault the user's call
|
|
494
|
+
pass
|
|
495
|
+
return default
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# Parent-span propagation
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
def propagate_user_prompt_to_parent(last_user_parts: Optional[list[dict[str, Any]]]) -> None:
|
|
503
|
+
"""Set the last user message on the parent invoke_agent span (write-once).
|
|
504
|
+
|
|
505
|
+
This lets the waterfall UI show what the user asked without drilling into
|
|
506
|
+
the child chat span. Provider-agnostic: the caller extracts the last user
|
|
507
|
+
message's spec parts (Anthropic message blocks vs Responses items differ);
|
|
508
|
+
this just stamps them once on the ambient ``_current_agent_span``.
|
|
509
|
+
"""
|
|
510
|
+
try:
|
|
511
|
+
if not last_user_parts:
|
|
512
|
+
return
|
|
513
|
+
from struct_sdk.core import _current_agent_span
|
|
514
|
+
agent_span = _current_agent_span.get(None)
|
|
515
|
+
if agent_span is None:
|
|
516
|
+
return
|
|
517
|
+
# Only set once — the first chat call within an invoke_agent scope wins.
|
|
518
|
+
existing = None
|
|
519
|
+
if hasattr(agent_span, "attributes"):
|
|
520
|
+
existing = agent_span.attributes.get("gen_ai.input.messages") # type: ignore[union-attr]
|
|
521
|
+
if existing:
|
|
522
|
+
return
|
|
523
|
+
agent_span.set_attribute(
|
|
524
|
+
"gen_ai.input.messages",
|
|
525
|
+
truncate_and_serialize([{"role": "user", "parts": last_user_parts}]),
|
|
526
|
+
)
|
|
527
|
+
except Exception:
|
|
528
|
+
pass # Never break the application for telemetry
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def stamp_provider_once(agent_span: Any, provider: Optional[str]) -> None:
|
|
532
|
+
"""Write-once ``gen_ai.provider.name`` on an invoke_agent span.
|
|
533
|
+
|
|
534
|
+
The GenAI spec puts ``gen_ai.provider.name`` on invoke_agent spans, but
|
|
535
|
+
the layer that CREATES those spans (``struct.agent()``, the LangChain
|
|
536
|
+
handler) is provider-agnostic — only a child inference call knows the
|
|
537
|
+
value. The first child call stamps it; an agent that never reaches a
|
|
538
|
+
model omits the attribute rather than carrying a framework name.
|
|
539
|
+
|
|
540
|
+
CONTRACT: ``agent_span`` is always an SDK-OWNED span — created by our own
|
|
541
|
+
tracer in ``struct.agent()`` or the LangChain handler, and delivered here
|
|
542
|
+
via ``_current_agent_span`` / the handler's run map, which nothing else
|
|
543
|
+
writes. It is never a host-supplied object. That is why the industry-
|
|
544
|
+
standard owned-object pattern applies (state lives ON the object, like
|
|
545
|
+
Sentry/dd-trace private span fields): a private sentinel attribute set
|
|
546
|
+
after a successful write. No global registries, locks, or weakref
|
|
547
|
+
lifecycles — those are for FOREIGN objects (see AGENTS.md: prefer
|
|
548
|
+
industry-standard telemetry patterns; don't defend interfaces no call
|
|
549
|
+
path can violate).
|
|
550
|
+
|
|
551
|
+
Semantics: "a real child provider" — if two child calls with DIFFERENT
|
|
552
|
+
providers race in the same instant, either may win; both are real
|
|
553
|
+
providers, and the semconv does not privilege one for a multi-provider
|
|
554
|
+
agent. The sentinel is set only after a successful write, so a transient
|
|
555
|
+
``set_attribute`` failure can be retried by the next child call.
|
|
556
|
+
"""
|
|
557
|
+
try:
|
|
558
|
+
if not provider or agent_span is None:
|
|
559
|
+
return
|
|
560
|
+
if getattr(agent_span, "_struct_provider_stamped", False):
|
|
561
|
+
return
|
|
562
|
+
agent_span.set_attribute("gen_ai.provider.name", provider)
|
|
563
|
+
try:
|
|
564
|
+
agent_span._struct_provider_stamped = True
|
|
565
|
+
except Exception:
|
|
566
|
+
# Frozen/slotted span: sentinel unavailable. Worst case a later
|
|
567
|
+
# child re-stamps another REAL provider — acceptable per the
|
|
568
|
+
# semantics above.
|
|
569
|
+
pass
|
|
570
|
+
except Exception:
|
|
571
|
+
pass # Never break the application for telemetry
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def propagate_provider_to_parent(provider: Optional[str]) -> None:
|
|
575
|
+
"""Stamp the ambient ``_current_agent_span`` with the child call's provider."""
|
|
576
|
+
try:
|
|
577
|
+
from struct_sdk.core import _current_agent_span
|
|
578
|
+
stamp_provider_once(_current_agent_span.get(None), provider)
|
|
579
|
+
except Exception:
|
|
580
|
+
pass # Never break the application for telemetry
|