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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: struct-sdk
3
- Version: 0.2.5
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
- # Required: wrap each tool execution.
219
- # tool_call_id is auto-filled from the preceding Anthropic response.
220
- async with struct.tool(name="search"):
221
- result = await search(...)
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
- # Required: wrap each tool execution.
173
- # tool_call_id is auto-filled from the preceding Anthropic response.
174
- async with struct.tool(name="search"):
175
- result = await search(...)
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.5"
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. **Enrich** — when ``_current_langchain_chat_span`` is set, this call
123
- is happening underneath a LangChain handler that's already created
124
- a ``chat <model>`` span. We do NOT create our own span (that's the
125
- duplicate-Anthropic-spans issue). Instead we attach HTTP-layer
126
- attrs (the real provider msg_id, exact response_model, usage,
127
- finish_reasons, error info on failure) onto the langchain span.
128
- Pre-call attrs are skipped LangChain already set them.
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, _current_langchain_chat_span
130
+ from struct_sdk.core import _safe, is_genai_suppressed
134
131
 
135
132
  model = kwargs.get("model", "unknown")
136
133
 
137
- # Enrich path: a LangChain handler upstream already created a ``chat
138
- # <model>`` span for this call. Attach Anthropic HTTP-layer detail to it
139
- # without creating a duplicate span.
140
- host_span = _current_langchain_chat_span.get(None)
141
- if host_span is not None:
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
- KNOWN GAP: this wrapper creates the chat span and stashes it on the
318
- stream_manager for use by downstream code, but does NOT currently hook
319
- the stream's finalization to call _set_response_attrs on the
320
- accumulated final message. As a result, streaming chat calls do not
321
- populate the _pending_tool_calls contextvar, and ``@struct.tool()``
322
- callers downstream of a ``messages.stream(...)`` call must pass
323
- ``tool_call_id=`` explicitly until this is fixed. Non-streaming
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
- async def wrapper(*args: Any, **kwargs: Any) -> Any:
329
- from struct_sdk.core import _safe, _current_session_id, _current_langchain_chat_span
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
- # Enrich path: a LangChain handler upstream already owns a chat
333
- # span for this call. Don't create a duplicate; just pass through.
334
- # (Stream end-handling will set response attrs on the host span
335
- # when the LangChain handler's on_llm_end fires.)
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 = await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
464
+ stream_manager = original(*args, **kwargs)
367
465
 
368
- if span is not None:
369
- def stash_attrs() -> None:
370
- stream_manager._struct_span = span # type: ignore[attr-defined]
371
- stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
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
- _safe(stash_attrs, site="anthropic.stream.stash_attrs")
471
+ def wrap_mgr() -> None:
472
+ nonlocal result
473
+ result = _TracedAsyncStreamManager(stream_manager, span, sdk, model, otel_logger)
376
474
 
377
- return stream_manager
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, _current_langchain_chat_span
480
+ from struct_sdk.core import _safe, _current_session_id, is_genai_suppressed
382
481
  model = kwargs.get("model", "unknown")
383
482
 
384
- # Enrich path: a LangChain handler upstream already owns a chat
385
- # span for this call. Don't create a duplicate; just pass through.
386
- if _current_langchain_chat_span.get(None) is not None:
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 not None:
419
- def stash_attrs() -> None:
420
- stream_manager._struct_span = span # type: ignore[attr-defined]
421
- stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
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
- _safe(stash_attrs, site="anthropic.stream.stash_attrs")
522
+ def wrap_mgr() -> None:
523
+ nonlocal result
524
+ result = _TracedStreamManager(stream_manager, span, sdk, model, otel_logger)
426
525
 
427
- return stream_manager
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.system``,
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.system": "anthropic",
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.system": "anthropic",
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.system": "anthropic",
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)