struct-sdk 0.2.5__tar.gz → 0.2.9__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.5 → struct_sdk-0.2.9}/PKG-INFO +63 -9
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/README.md +62 -8
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/pyproject.toml +6 -1
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/src/struct_sdk/anthropic.py +175 -80
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/src/struct_sdk/core.py +324 -79
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/src/struct_sdk/langchain.py +248 -95
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/.gitignore +0 -0
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/LICENSE +0 -0
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.2.5 → struct_sdk-0.2.9}/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.9
|
|
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
|
|
@@ -87,16 +87,18 @@ struct.init(
|
|
|
87
87
|
import anthropic
|
|
88
88
|
client = anthropic.AsyncAnthropic()
|
|
89
89
|
|
|
90
|
+
# Decorate each tool — auto-captures arguments + result + tool_call_id.
|
|
91
|
+
@struct.tool()
|
|
92
|
+
async def search(query: str):
|
|
93
|
+
...
|
|
94
|
+
|
|
90
95
|
async with struct.agent(name="checkout"):
|
|
91
96
|
msg = await client.messages.create(
|
|
92
97
|
model="claude-3-5-sonnet-20241022",
|
|
93
98
|
max_tokens=1024,
|
|
94
99
|
messages=[{"role": "user", "content": "plan my checkout flow"}],
|
|
95
100
|
)
|
|
96
|
-
|
|
97
|
-
# tool_call_id is auto-filled from the preceding Anthropic response
|
|
98
|
-
async with struct.tool(name="search"):
|
|
99
|
-
result = await search(msg)
|
|
101
|
+
result = await search(query="...")
|
|
100
102
|
```
|
|
101
103
|
|
|
102
104
|
## What gets traced
|
|
@@ -207,6 +209,14 @@ struct.init(ingest_key="pk-...", service_name="checkout-agent")
|
|
|
207
209
|
import anthropic
|
|
208
210
|
client = anthropic.AsyncAnthropic()
|
|
209
211
|
|
|
212
|
+
# Recommended: define each tool as a function and DECORATE it. The decorator
|
|
213
|
+
# auto-captures the tool's arguments + result on the execute_tool span and
|
|
214
|
+
# auto-fills tool_call_id from the preceding Anthropic response — no manual
|
|
215
|
+
# bookkeeping.
|
|
216
|
+
@struct.tool()
|
|
217
|
+
async def search(query: str):
|
|
218
|
+
...
|
|
219
|
+
|
|
210
220
|
# Required: wrap the agent loop yourself.
|
|
211
221
|
async with struct.agent(name="checkout"):
|
|
212
222
|
msg = await client.messages.create(
|
|
@@ -214,16 +224,60 @@ async with struct.agent(name="checkout"):
|
|
|
214
224
|
max_tokens=1024,
|
|
215
225
|
messages=[...],
|
|
216
226
|
)
|
|
227
|
+
# Dispatching a decorated tool inside the agent emits a fully-populated
|
|
228
|
+
# execute_tool span (name, id, arguments, result):
|
|
229
|
+
result = await search(query="...")
|
|
230
|
+
```
|
|
217
231
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
232
|
+
For **dynamic dispatch** (the LLM picks a tool from a registry at runtime),
|
|
233
|
+
apply the decorator at runtime — still automatic, just bind the name when you
|
|
234
|
+
wrap the callable:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
registry = {t.name: struct.tool(name=t.name)(t.execute) for t in tools}
|
|
238
|
+
result = await registry[block.name](**block.input) # arguments + result captured
|
|
222
239
|
```
|
|
223
240
|
|
|
241
|
+
> `struct.tool()` can also be used as a context manager
|
|
242
|
+
> (`async with struct.tool(name=...): ...`) to instrument an arbitrary block of
|
|
243
|
+
> code as a tool span. That form is a **manual escape hatch** — it does NOT
|
|
244
|
+
> auto-capture arguments/result (a `with` block can't see the body's return
|
|
245
|
+
> value), so prefer the decorator for actual tool calls. See
|
|
246
|
+
> [Parallel tool calls](#parallel-tool-calls--pass-tool_call_id-explicitly) for
|
|
247
|
+
> the one runtime value (`tool_call_id`) you must supply under concurrency.
|
|
248
|
+
|
|
224
249
|
`anthropic.Anthropic`, `anthropic.AsyncAnthropic`, and the bedrock/vertex
|
|
225
250
|
clients are all auto-instrumented for chat spans.
|
|
226
251
|
|
|
252
|
+
#### Parallel tool calls — pass `tool_call_id` explicitly
|
|
253
|
+
|
|
254
|
+
When you execute an assistant turn's tool calls **sequentially** — one
|
|
255
|
+
`await` at a time, in the order the `tool_use` blocks appear — `struct.tool()`
|
|
256
|
+
auto-fills `gen_ai.tool.call.id` by matching each span to the next pending
|
|
257
|
+
`tool_use` of the same tool name. Nothing extra to do.
|
|
258
|
+
|
|
259
|
+
When you execute them **concurrently** (e.g. `asyncio.gather`), that
|
|
260
|
+
name-and-order matching is ambiguous: two `struct.tool(name="search")` spans
|
|
261
|
+
can start in any order, so the auto-fill may attach the wrong id (and thus the
|
|
262
|
+
wrong arguments/result) to a call. In that case **pass `tool_call_id`
|
|
263
|
+
explicitly** from the originating `tool_use` block — an explicit id always
|
|
264
|
+
overrides the auto-linkage:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
async def run_one(block):
|
|
268
|
+
# The id from THIS block overrides the name/order auto-fill.
|
|
269
|
+
async with struct.tool(name=block.name, tool_call_id=block.id):
|
|
270
|
+
return await dispatch(block.name, **block.input)
|
|
271
|
+
|
|
272
|
+
# Concurrent execution — each tool span still carries the correct id.
|
|
273
|
+
results = await asyncio.gather(*[run_one(b) for b in tool_use_blocks])
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Rule of thumb: **serial tool execution → automatic; concurrent tool execution
|
|
277
|
+
→ provide `tool_call_id=` yourself.** (Auto-instrumented frameworks such as
|
|
278
|
+
LangChain read the id from the framework's `ToolCall`, so this only applies
|
|
279
|
+
when you drive the tool loop directly against an LLM SDK.)
|
|
280
|
+
|
|
227
281
|
#### LangChain `BaseChatModel` (no agent/graph)
|
|
228
282
|
|
|
229
283
|
If you call `ChatAnthropic.invoke(...)` (or any other `BaseChatModel`)
|
|
@@ -41,16 +41,18 @@ struct.init(
|
|
|
41
41
|
import anthropic
|
|
42
42
|
client = anthropic.AsyncAnthropic()
|
|
43
43
|
|
|
44
|
+
# Decorate each tool — auto-captures arguments + result + tool_call_id.
|
|
45
|
+
@struct.tool()
|
|
46
|
+
async def search(query: str):
|
|
47
|
+
...
|
|
48
|
+
|
|
44
49
|
async with struct.agent(name="checkout"):
|
|
45
50
|
msg = await client.messages.create(
|
|
46
51
|
model="claude-3-5-sonnet-20241022",
|
|
47
52
|
max_tokens=1024,
|
|
48
53
|
messages=[{"role": "user", "content": "plan my checkout flow"}],
|
|
49
54
|
)
|
|
50
|
-
|
|
51
|
-
# tool_call_id is auto-filled from the preceding Anthropic response
|
|
52
|
-
async with struct.tool(name="search"):
|
|
53
|
-
result = await search(msg)
|
|
55
|
+
result = await search(query="...")
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
## What gets traced
|
|
@@ -161,6 +163,14 @@ struct.init(ingest_key="pk-...", service_name="checkout-agent")
|
|
|
161
163
|
import anthropic
|
|
162
164
|
client = anthropic.AsyncAnthropic()
|
|
163
165
|
|
|
166
|
+
# Recommended: define each tool as a function and DECORATE it. The decorator
|
|
167
|
+
# auto-captures the tool's arguments + result on the execute_tool span and
|
|
168
|
+
# auto-fills tool_call_id from the preceding Anthropic response — no manual
|
|
169
|
+
# bookkeeping.
|
|
170
|
+
@struct.tool()
|
|
171
|
+
async def search(query: str):
|
|
172
|
+
...
|
|
173
|
+
|
|
164
174
|
# Required: wrap the agent loop yourself.
|
|
165
175
|
async with struct.agent(name="checkout"):
|
|
166
176
|
msg = await client.messages.create(
|
|
@@ -168,16 +178,60 @@ async with struct.agent(name="checkout"):
|
|
|
168
178
|
max_tokens=1024,
|
|
169
179
|
messages=[...],
|
|
170
180
|
)
|
|
181
|
+
# Dispatching a decorated tool inside the agent emits a fully-populated
|
|
182
|
+
# execute_tool span (name, id, arguments, result):
|
|
183
|
+
result = await search(query="...")
|
|
184
|
+
```
|
|
171
185
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
186
|
+
For **dynamic dispatch** (the LLM picks a tool from a registry at runtime),
|
|
187
|
+
apply the decorator at runtime — still automatic, just bind the name when you
|
|
188
|
+
wrap the callable:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
registry = {t.name: struct.tool(name=t.name)(t.execute) for t in tools}
|
|
192
|
+
result = await registry[block.name](**block.input) # arguments + result captured
|
|
176
193
|
```
|
|
177
194
|
|
|
195
|
+
> `struct.tool()` can also be used as a context manager
|
|
196
|
+
> (`async with struct.tool(name=...): ...`) to instrument an arbitrary block of
|
|
197
|
+
> code as a tool span. That form is a **manual escape hatch** — it does NOT
|
|
198
|
+
> auto-capture arguments/result (a `with` block can't see the body's return
|
|
199
|
+
> value), so prefer the decorator for actual tool calls. See
|
|
200
|
+
> [Parallel tool calls](#parallel-tool-calls--pass-tool_call_id-explicitly) for
|
|
201
|
+
> the one runtime value (`tool_call_id`) you must supply under concurrency.
|
|
202
|
+
|
|
178
203
|
`anthropic.Anthropic`, `anthropic.AsyncAnthropic`, and the bedrock/vertex
|
|
179
204
|
clients are all auto-instrumented for chat spans.
|
|
180
205
|
|
|
206
|
+
#### Parallel tool calls — pass `tool_call_id` explicitly
|
|
207
|
+
|
|
208
|
+
When you execute an assistant turn's tool calls **sequentially** — one
|
|
209
|
+
`await` at a time, in the order the `tool_use` blocks appear — `struct.tool()`
|
|
210
|
+
auto-fills `gen_ai.tool.call.id` by matching each span to the next pending
|
|
211
|
+
`tool_use` of the same tool name. Nothing extra to do.
|
|
212
|
+
|
|
213
|
+
When you execute them **concurrently** (e.g. `asyncio.gather`), that
|
|
214
|
+
name-and-order matching is ambiguous: two `struct.tool(name="search")` spans
|
|
215
|
+
can start in any order, so the auto-fill may attach the wrong id (and thus the
|
|
216
|
+
wrong arguments/result) to a call. In that case **pass `tool_call_id`
|
|
217
|
+
explicitly** from the originating `tool_use` block — an explicit id always
|
|
218
|
+
overrides the auto-linkage:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
async def run_one(block):
|
|
222
|
+
# The id from THIS block overrides the name/order auto-fill.
|
|
223
|
+
async with struct.tool(name=block.name, tool_call_id=block.id):
|
|
224
|
+
return await dispatch(block.name, **block.input)
|
|
225
|
+
|
|
226
|
+
# Concurrent execution — each tool span still carries the correct id.
|
|
227
|
+
results = await asyncio.gather(*[run_one(b) for b in tool_use_blocks])
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Rule of thumb: **serial tool execution → automatic; concurrent tool execution
|
|
231
|
+
→ provide `tool_call_id=` yourself.** (Auto-instrumented frameworks such as
|
|
232
|
+
LangChain read the id from the framework's `ToolCall`, so this only applies
|
|
233
|
+
when you drive the tool loop directly against an LLM SDK.)
|
|
234
|
+
|
|
181
235
|
#### LangChain `BaseChatModel` (no agent/graph)
|
|
182
236
|
|
|
183
237
|
If you call `ChatAnthropic.invoke(...)` (or any other `BaseChatModel`)
|
|
@@ -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.9"
|
|
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"
|
|
@@ -72,6 +72,11 @@ override-dependencies = [
|
|
|
72
72
|
"starlette>=1.3.1",
|
|
73
73
|
]
|
|
74
74
|
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
markers = [
|
|
77
|
+
"integration: real-model integration tests (requires ANTHROPIC_API_KEY / OPENAI_API_KEY; skipped in default CI)",
|
|
78
|
+
]
|
|
79
|
+
|
|
75
80
|
[tool.mypy]
|
|
76
81
|
[[tool.mypy.overrides]]
|
|
77
82
|
module = ["anthropic", "anthropic.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*"]
|
|
@@ -119,44 +119,23 @@ def _create_common(
|
|
|
119
119
|
|
|
120
120
|
Two paths:
|
|
121
121
|
|
|
122
|
-
1. **
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
duplicate-Anthropic-spans
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
2. **Standalone** — no LangChain in the picture. Create our own span
|
|
131
|
-
and set the full attribute set as before.
|
|
122
|
+
1. **Suppressed** — when ``is_genai_suppressed()`` is True, a framework
|
|
123
|
+
layer (e.g. the LangChain callback handler) already owns a ``chat
|
|
124
|
+
<model>`` span for this call. We run the original call to completion
|
|
125
|
+
and emit NO span — avoiding the duplicate-Anthropic-spans problem.
|
|
126
|
+
|
|
127
|
+
2. **Standalone** — no framework suppression in the picture. Create our
|
|
128
|
+
own span and set the full attribute set as before.
|
|
132
129
|
"""
|
|
133
|
-
from struct_sdk.core import _safe,
|
|
130
|
+
from struct_sdk.core import _safe, is_genai_suppressed
|
|
134
131
|
|
|
135
132
|
model = kwargs.get("model", "unknown")
|
|
136
133
|
|
|
137
|
-
#
|
|
138
|
-
# <model>`` span for this call.
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
try:
|
|
143
|
-
result = yield f, args, kwargs
|
|
144
|
-
except Exception as e:
|
|
145
|
-
# Capture the type name OUTSIDE the lambda — ``except X as e``
|
|
146
|
-
# binds ``e`` only for the duration of the except block, but
|
|
147
|
-
# ``_safe`` is opaque to static analysis (ruff flags F841 +
|
|
148
|
-
# F821 thinking the lambda outlives the binding). Snapshotting
|
|
149
|
-
# to a local makes the closure capture trivially correct.
|
|
150
|
-
err_type = type(e).__name__
|
|
151
|
-
_safe(
|
|
152
|
-
lambda: host_span.set_attribute("error.type", err_type),
|
|
153
|
-
site="anthropic.create.enrich.error_type",
|
|
154
|
-
)
|
|
155
|
-
raise
|
|
156
|
-
_safe(
|
|
157
|
-
lambda: _set_response_attrs(host_span, sdk, model, result, otel_logger),
|
|
158
|
-
site="anthropic.create.enrich.set_response_attrs",
|
|
159
|
-
)
|
|
134
|
+
# Suppression path: a framework layer (LangChain handler) already owns the
|
|
135
|
+
# ``chat <model>`` span for this call. Run the original call to completion
|
|
136
|
+
# and emit NO span — the framework's span covers this invocation.
|
|
137
|
+
if is_genai_suppressed():
|
|
138
|
+
result = yield f, args, kwargs
|
|
160
139
|
return result # noqa: B901
|
|
161
140
|
|
|
162
141
|
with tracer.start_as_current_span(
|
|
@@ -311,30 +290,149 @@ def _wrap_create(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
311
290
|
# messages.stream — context manager wrapping
|
|
312
291
|
# ---------------------------------------------------------------------------
|
|
313
292
|
|
|
293
|
+
def _finish_stream_span(
|
|
294
|
+
span: trace.Span, stream: Any, sdk: "StructSDK", model: str, otel_logger: Any, exc: tuple
|
|
295
|
+
) -> None:
|
|
296
|
+
"""End the streaming ``chat`` span EXACTLY ONCE on block exit: populate
|
|
297
|
+
response attributes from the accumulated final message (best-effort — the
|
|
298
|
+
stream may have been left partially consumed), set status, and end. All
|
|
299
|
+
_safe-wrapped so a broken stream can never fault the host's ``with`` block."""
|
|
300
|
+
from struct_sdk.core import _safe
|
|
301
|
+
|
|
302
|
+
def body() -> None:
|
|
303
|
+
final_msg = None
|
|
304
|
+
if stream is not None:
|
|
305
|
+
try:
|
|
306
|
+
getter = getattr(stream, "get_final_message", None)
|
|
307
|
+
final_msg = getter() if getter is not None else None
|
|
308
|
+
except Exception: # noqa: BLE001 — partial/aborted streams have no final message
|
|
309
|
+
final_msg = None
|
|
310
|
+
if final_msg is not None:
|
|
311
|
+
_set_response_attrs(span, sdk, model, final_msg, otel_logger)
|
|
312
|
+
if exc and exc[0] is not None:
|
|
313
|
+
span.set_attribute("error.type", exc[0].__name__)
|
|
314
|
+
span.set_status(StatusCode.ERROR, str(exc[1]))
|
|
315
|
+
span.record_exception(exc[1])
|
|
316
|
+
else:
|
|
317
|
+
span.set_status(StatusCode.OK)
|
|
318
|
+
|
|
319
|
+
_safe(body, site="anthropic.stream.finish")
|
|
320
|
+
_safe(span.end, site="anthropic.stream.end")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class _TracedStreamManager:
|
|
324
|
+
"""Wraps a sync Anthropic ``MessageStreamManager`` so the chat span ends
|
|
325
|
+
exactly once when the customer's ``with`` block exits (the only guaranteed
|
|
326
|
+
single termination point), without making the span the ambient current
|
|
327
|
+
context across iteration."""
|
|
328
|
+
|
|
329
|
+
def __init__(self, inner: Any, span: trace.Span, sdk: "StructSDK", model: str, otel_logger: Any) -> None:
|
|
330
|
+
self._inner = inner
|
|
331
|
+
self._stream: Any = None
|
|
332
|
+
self._ended = False
|
|
333
|
+
# Back-compat: downstream code may read these stashed attrs.
|
|
334
|
+
self._struct_span = span
|
|
335
|
+
self._struct_sdk = sdk
|
|
336
|
+
self._struct_model = model
|
|
337
|
+
self._struct_logger = otel_logger
|
|
338
|
+
|
|
339
|
+
def __enter__(self) -> Any:
|
|
340
|
+
# Anthropic does request setup on enter, so __enter__ can raise
|
|
341
|
+
# (auth/connection). If it does, __exit__ never runs — end the span here
|
|
342
|
+
# or it leaks unexported. Re-raise so the customer still sees their error.
|
|
343
|
+
try:
|
|
344
|
+
self._stream = self._inner.__enter__()
|
|
345
|
+
except BaseException as e:
|
|
346
|
+
self._finish_once((type(e), e, e.__traceback__))
|
|
347
|
+
raise
|
|
348
|
+
return self._stream
|
|
349
|
+
|
|
350
|
+
def __exit__(self, *exc: Any) -> Any:
|
|
351
|
+
try:
|
|
352
|
+
return self._inner.__exit__(*exc)
|
|
353
|
+
finally:
|
|
354
|
+
self._finish_once(exc)
|
|
355
|
+
|
|
356
|
+
def _finish_once(self, exc: Any) -> None:
|
|
357
|
+
if not self._ended:
|
|
358
|
+
self._ended = True
|
|
359
|
+
_finish_stream_span(
|
|
360
|
+
self._struct_span, self._stream, self._struct_sdk,
|
|
361
|
+
self._struct_model, self._struct_logger, exc,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def __getattr__(self, name: str) -> Any:
|
|
365
|
+
# Delegate unknown attributes to the wrapped manager (e.g. .text_stream).
|
|
366
|
+
return getattr(object.__getattribute__(self, "_inner"), name)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class _TracedAsyncStreamManager:
|
|
370
|
+
"""Async twin of ``_TracedStreamManager``."""
|
|
371
|
+
|
|
372
|
+
def __init__(self, inner: Any, span: trace.Span, sdk: "StructSDK", model: str, otel_logger: Any) -> None:
|
|
373
|
+
self._inner = inner
|
|
374
|
+
self._stream: Any = None
|
|
375
|
+
self._ended = False
|
|
376
|
+
self._struct_span = span
|
|
377
|
+
self._struct_sdk = sdk
|
|
378
|
+
self._struct_model = model
|
|
379
|
+
self._struct_logger = otel_logger
|
|
380
|
+
|
|
381
|
+
async def __aenter__(self) -> Any:
|
|
382
|
+
# See _TracedStreamManager.__enter__: a failed enter must still end the
|
|
383
|
+
# span (it would otherwise leak unexported), then re-raise.
|
|
384
|
+
try:
|
|
385
|
+
self._stream = await self._inner.__aenter__()
|
|
386
|
+
except BaseException as e:
|
|
387
|
+
self._finish_once((type(e), e, e.__traceback__))
|
|
388
|
+
raise
|
|
389
|
+
return self._stream
|
|
390
|
+
|
|
391
|
+
async def __aexit__(self, *exc: Any) -> Any:
|
|
392
|
+
try:
|
|
393
|
+
return await self._inner.__aexit__(*exc)
|
|
394
|
+
finally:
|
|
395
|
+
self._finish_once(exc)
|
|
396
|
+
|
|
397
|
+
def _finish_once(self, exc: Any) -> None:
|
|
398
|
+
if not self._ended:
|
|
399
|
+
self._ended = True
|
|
400
|
+
_finish_stream_span(
|
|
401
|
+
self._struct_span, self._stream, self._struct_sdk,
|
|
402
|
+
self._struct_model, self._struct_logger, exc,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def __getattr__(self, name: str) -> Any:
|
|
406
|
+
return getattr(object.__getattribute__(self, "_inner"), name)
|
|
407
|
+
|
|
408
|
+
|
|
314
409
|
def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logger: Any, is_async: bool) -> Any:
|
|
315
410
|
"""Wrap messages.stream() to trace the streaming context manager.
|
|
316
411
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
accumulated final message.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
``messages.create()`` is fully covered.
|
|
412
|
+
The returned manager is wrapped in ``_TracedStreamManager`` /
|
|
413
|
+
``_TracedAsyncStreamManager`` so the ``chat`` span ENDS exactly once when the
|
|
414
|
+
customer's ``with``/``async with`` block exits, with response attributes
|
|
415
|
+
populated from the accumulated final message (best-effort). The span is never
|
|
416
|
+
made the ambient current context across iteration — there is no token to
|
|
417
|
+
detach on a foreign task. (Pre-fix this wrapper only stashed the span and
|
|
418
|
+
never ended it, dropping every streaming chat from telemetry.)
|
|
325
419
|
"""
|
|
326
420
|
if is_async:
|
|
421
|
+
# NOTE: Anthropic's async ``messages.stream()`` is a SYNC method that
|
|
422
|
+
# returns an ``AsyncMessageStreamManager`` — customers write
|
|
423
|
+
# ``async with client.messages.stream(...) as s:`` WITHOUT awaiting the
|
|
424
|
+
# call. So this wrapper must be SYNC and return the manager directly
|
|
425
|
+
# (the async work lives in the manager's __aenter__/__aexit__). Making it
|
|
426
|
+
# ``async def`` would return a coroutine and break ``async with``.
|
|
327
427
|
@functools.wraps(original)
|
|
328
|
-
|
|
329
|
-
from struct_sdk.core import _safe, _current_session_id,
|
|
428
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
429
|
+
from struct_sdk.core import _safe, _current_session_id, is_genai_suppressed
|
|
330
430
|
model = kwargs.get("model", "unknown")
|
|
331
431
|
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if _current_langchain_chat_span.get(None) is not None:
|
|
337
|
-
return await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
|
|
432
|
+
# Suppression path: a framework layer (LangChain handler) already
|
|
433
|
+
# owns the chat span. Don't create a duplicate; pass through.
|
|
434
|
+
if is_genai_suppressed():
|
|
435
|
+
return original(*args, **kwargs)
|
|
338
436
|
|
|
339
437
|
span: Optional[trace.Span] = None
|
|
340
438
|
|
|
@@ -363,27 +461,28 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
363
461
|
|
|
364
462
|
_safe(set_pre_call_attrs, site="anthropic.stream.pre_call_attrs")
|
|
365
463
|
|
|
366
|
-
stream_manager =
|
|
464
|
+
stream_manager = original(*args, **kwargs)
|
|
367
465
|
|
|
368
|
-
if span is
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
stream_manager._struct_model = model # type: ignore[attr-defined]
|
|
373
|
-
stream_manager._struct_logger = otel_logger # type: ignore[attr-defined]
|
|
466
|
+
if span is None:
|
|
467
|
+
return stream_manager
|
|
468
|
+
# Wrap so the chat span ENDS exactly once on block exit (was leaked).
|
|
469
|
+
result: Any = stream_manager
|
|
374
470
|
|
|
375
|
-
|
|
471
|
+
def wrap_mgr() -> None:
|
|
472
|
+
nonlocal result
|
|
473
|
+
result = _TracedAsyncStreamManager(stream_manager, span, sdk, model, otel_logger)
|
|
376
474
|
|
|
377
|
-
|
|
475
|
+
_safe(wrap_mgr, site="anthropic.stream.wrap_async")
|
|
476
|
+
return result
|
|
378
477
|
else:
|
|
379
478
|
@functools.wraps(original)
|
|
380
479
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
381
|
-
from struct_sdk.core import _safe, _current_session_id,
|
|
480
|
+
from struct_sdk.core import _safe, _current_session_id, is_genai_suppressed
|
|
382
481
|
model = kwargs.get("model", "unknown")
|
|
383
482
|
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
if
|
|
483
|
+
# Suppression path: a framework layer (LangChain handler) already
|
|
484
|
+
# owns the chat span. Don't create a duplicate; pass through.
|
|
485
|
+
if is_genai_suppressed():
|
|
387
486
|
return original(*args, **kwargs)
|
|
388
487
|
|
|
389
488
|
span: Optional[trace.Span] = None
|
|
@@ -415,16 +514,17 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
415
514
|
|
|
416
515
|
stream_manager = original(*args, **kwargs)
|
|
417
516
|
|
|
418
|
-
if span is
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
stream_manager._struct_model = model # type: ignore[attr-defined]
|
|
423
|
-
stream_manager._struct_logger = otel_logger # type: ignore[attr-defined]
|
|
517
|
+
if span is None:
|
|
518
|
+
return stream_manager
|
|
519
|
+
# Wrap so the chat span ENDS exactly once on block exit (was leaked).
|
|
520
|
+
result: Any = stream_manager
|
|
424
521
|
|
|
425
|
-
|
|
522
|
+
def wrap_mgr() -> None:
|
|
523
|
+
nonlocal result
|
|
524
|
+
result = _TracedStreamManager(stream_manager, span, sdk, model, otel_logger)
|
|
426
525
|
|
|
427
|
-
|
|
526
|
+
_safe(wrap_mgr, site="anthropic.stream.wrap_sync")
|
|
527
|
+
return result
|
|
428
528
|
|
|
429
529
|
wrapper.__struct_wrapped__ = True # type: ignore[attr-defined]
|
|
430
530
|
wrapper.__struct_original__ = original # type: ignore[attr-defined]
|
|
@@ -552,7 +652,7 @@ def _emit_message_events(
|
|
|
552
652
|
etc.) — human-readable signal.
|
|
553
653
|
- ``attributes['body']`` (log record attribute): the JSON-serialised
|
|
554
654
|
structured payload ``{"role": ..., "parts": [...]}``.
|
|
555
|
-
- Other attributes: ``event.name``, ``gen_ai.
|
|
655
|
+
- Other attributes: ``event.name``, ``gen_ai.provider.name``,
|
|
556
656
|
``gen_ai.message.index``, ``gen_ai.conversation.id``.
|
|
557
657
|
|
|
558
658
|
``span`` — if provided, its span context is used for the LogRecord's
|
|
@@ -585,7 +685,7 @@ def _emit_message_events(
|
|
|
585
685
|
attrs: dict[str, Any] = {
|
|
586
686
|
"event.name": event_name,
|
|
587
687
|
"body": payload,
|
|
588
|
-
"gen_ai.
|
|
688
|
+
"gen_ai.provider.name": "anthropic",
|
|
589
689
|
"gen_ai.message.index": msg_index,
|
|
590
690
|
}
|
|
591
691
|
if session_id:
|
|
@@ -618,7 +718,7 @@ def _emit_message_events(
|
|
|
618
718
|
attrs = {
|
|
619
719
|
"event.name": event_name,
|
|
620
720
|
"body": payload,
|
|
621
|
-
"gen_ai.
|
|
721
|
+
"gen_ai.provider.name": "anthropic",
|
|
622
722
|
"gen_ai.message.index": msg_index,
|
|
623
723
|
}
|
|
624
724
|
if session_id:
|
|
@@ -696,7 +796,7 @@ def _emit_choice_event(
|
|
|
696
796
|
attrs: dict[str, Any] = {
|
|
697
797
|
"event.name": event_name,
|
|
698
798
|
"body": payload,
|
|
699
|
-
"gen_ai.
|
|
799
|
+
"gen_ai.provider.name": "anthropic",
|
|
700
800
|
}
|
|
701
801
|
if session_id:
|
|
702
802
|
attrs["gen_ai.conversation.id"] = session_id
|
|
@@ -996,8 +1096,3 @@ def _safe_json(obj: Any) -> str:
|
|
|
996
1096
|
return _truncate_and_serialize(obj)
|
|
997
1097
|
except Exception:
|
|
998
1098
|
return "[]"
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
def _is_coroutine(fn: Any) -> bool:
|
|
1002
|
-
import asyncio
|
|
1003
|
-
return asyncio.iscoroutinefunction(fn)
|