struct-sdk 0.2.30__tar.gz → 0.2.32__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.5
2
2
  Name: struct-sdk
3
- Version: 0.2.30
3
+ Version: 0.2.32
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "struct-sdk"
7
- version = "0.2.30"
7
+ version = "0.2.32"
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,9 @@ dev = [
72
72
  # `uv run pytest` with no extras).
73
73
  "openai>=1.66.0",
74
74
  "httpx>=0.27.0",
75
+ # The Anthropic raw-stream suite (tests/test_anthropic_raw_stream.py) drives
76
+ # the REAL anthropic client over a mocked httpx transport, for the same reason.
77
+ "anthropic>=0.30.0",
75
78
  ]
76
79
 
77
80
  [tool.uv]
@@ -38,6 +38,8 @@ from urllib.parse import urlparse
38
38
  from opentelemetry import trace
39
39
  from opentelemetry.trace import StatusCode
40
40
 
41
+ from struct_sdk.core import _contain_logged
42
+
41
43
  logger = logging.getLogger("struct_sdk._genai_content")
42
44
 
43
45
  # Max size for content attributes (JSON strings on spans).
@@ -251,20 +253,24 @@ def _contain(fn: Any, site: str) -> None:
251
253
  guarantees cannot happen. Deliberately NOT the global ``_safe``
252
254
  semantics: everywhere else, delaying a genuine Ctrl-C matters more than
253
255
  a hostile input.
256
+
257
+ Only the first fault per site carries a traceback (SDK-PY-19): a stream's
258
+ per-event hooks would otherwise format one per event.
254
259
  """
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
260
+ _contain_logged(fn, site=site)
262
261
 
263
262
 
264
263
  @contextlib.contextmanager
265
- def guarded_client_span(tracer: Any, span_name: str) -> Iterator[Any]:
264
+ def guarded_client_span(
265
+ tracer: Any, span_name: str, *, keep_open: Optional[Callable[[], bool]] = None,
266
+ ) -> Iterator[Any]:
266
267
  """Explicit CLIENT span with ``_safe``-guarded start and end.
267
268
 
269
+ ``keep_open`` hands the span's END to another owner: when it returns True at
270
+ exit, everything else still runs (the ambient context is detached on this
271
+ task) but the span stays open, and that owner must end it exactly once — the
272
+ raw ``create(stream=True)`` event stream, which ends it when the stream does.
273
+
268
274
  Deliberately NOT ``start_as_current_span``: ``use_span``'s exit runs
269
275
  ``span.end()`` — and with it every customer-added ``SpanProcessor.on_end``
270
276
  — plus its own ``record_exception``/``set_status`` (which stringify the
@@ -344,7 +350,7 @@ def guarded_client_span(tracer: Any, span_name: str) -> Iterator[Any]:
344
350
 
345
351
  _contain(finalize_base_exception, "genai.client_span.finalize_base")
346
352
  _contain(lambda: _detach_current_span(token, enter_task), "genai.client_span.detach")
347
- if span is not None:
353
+ if span is not None and not (keep_open is not None and keep_open()):
348
354
  _contain(span.end, "genai.client_span.end")
349
355
 
350
356
 
@@ -25,12 +25,16 @@ import functools
25
25
  import inspect
26
26
  import json
27
27
  import logging
28
+ import contextvars
29
+ import threading
30
+ from types import SimpleNamespace
28
31
  from typing import TYPE_CHECKING, Any, Callable, Generator, Optional
29
32
 
30
33
  from opentelemetry import trace
31
34
  from opentelemetry.trace import StatusCode
32
35
 
33
36
  from struct_sdk._genai_content import (
37
+ _MAX_FIELD_SIZE,
34
38
  detect_provider_from_resource,
35
39
  emit_choice_event,
36
40
  emit_message_event,
@@ -54,6 +58,9 @@ logger = logging.getLogger("struct_sdk.anthropic")
54
58
  # Truncation helpers are shared with the OpenAI integration via
55
59
  # ``struct_sdk._genai_content``; re-exported here under their original private
56
60
  # names so existing internal callers keep working unchanged.
61
+ _AMBIENT: Any = object()
62
+ """Sentinel: record tool-use ids into the current context's queue (the default)."""
63
+
57
64
  _truncate_field = truncate_field
58
65
  _truncate_parts = truncate_parts
59
66
  _truncate_and_serialize = truncate_and_serialize
@@ -146,6 +153,10 @@ def patch(sdk: StructSDK) -> None:
146
153
  if hasattr(async_cls, "stream") and not getattr(async_cls.stream, "__struct_wrapped__", False):
147
154
  async_cls.stream = _wrap_stream(async_cls.stream, tracer, sdk, otel_logger, is_async=True) # type: ignore[method-assign]
148
155
 
156
+ for stream_cls in (anthropic.Stream, anthropic.AsyncStream):
157
+ if not getattr(stream_cls.close, "__struct_close_hook__", False):
158
+ stream_cls.close = _close_hook(stream_cls.close) # type: ignore[method-assign]
159
+
149
160
  anthropic.__struct_patched = True # type: ignore[attr-defined]
150
161
 
151
162
 
@@ -165,6 +176,9 @@ def unpatch() -> None:
165
176
  method = getattr(cls, method_name, None)
166
177
  if method and getattr(method, "__struct_wrapped__", False):
167
178
  setattr(cls, method_name, method.__struct_original__)
179
+ for stream_cls in (anthropic.Stream, anthropic.AsyncStream):
180
+ if getattr(stream_cls.close, "__struct_close_hook__", False):
181
+ stream_cls.close = getattr(stream_cls.close, "__struct_original__") # type: ignore[method-assign]
168
182
 
169
183
  anthropic.__struct_patched = False # type: ignore[attr-defined]
170
184
 
@@ -214,7 +228,9 @@ def _create_common(
214
228
  # guarded_client_span, not start_as_current_span: use_span's exit runs
215
229
  # span.end() (=> every customer SpanProcessor.on_end) and its own
216
230
  # record_exception unguarded on the gen.send/gen.throw host path.
217
- with guarded_client_span(tracer, f"chat {model}") as span:
231
+ # ``handed_off``: a raw ``stream=True`` result took over ending the span.
232
+ handed_off = False
233
+ with guarded_client_span(tracer, f"chat {model}", keep_open=lambda: handed_off) as span:
218
234
  def set_pre_call_attrs() -> None:
219
235
  # Required attributes
220
236
  span.set_attribute("gen_ai.operation.name", "chat")
@@ -285,6 +301,16 @@ def _create_common(
285
301
  site="anthropic.create.record_exception",
286
302
  )
287
303
  raise
304
+ if kwargs.get("stream") and span is not None:
305
+ # The raw event stream: the response (and its usage) is still to
306
+ # come, so the span ends when the stream does ([SDK-PY-24]).
307
+ def hand_off() -> None:
308
+ nonlocal handed_off
309
+ handed_off = _instrument_raw_stream(result, span, sdk, model, otel_logger, provider)
310
+
311
+ _safe(hand_off, site="anthropic.create.raw_stream")
312
+ if handed_off:
313
+ return result # noqa: B901
288
314
  _safe(lambda: _set_response_attrs(span, sdk, model, result, otel_logger, provider),
289
315
  site="anthropic.create.set_response_attrs")
290
316
  _safe(lambda: span.set_status(StatusCode.OK),
@@ -292,6 +318,370 @@ def _create_common(
292
318
  return result # noqa: B901
293
319
 
294
320
 
321
+ # ---------------------------------------------------------------------------
322
+ # messages.create(stream=True) — the raw event stream
323
+ # ---------------------------------------------------------------------------
324
+ #
325
+ # ``create(stream=True)`` returns the provider's ``Stream`` / ``AsyncStream`` of
326
+ # raw SSE events; usage, the response id and the content only arrive as the
327
+ # caller iterates. The SDK keeps the host's stream object (identity, type and
328
+ # every event unchanged) and swaps in an iterator that observes each event and
329
+ # ends the ``chat`` span exactly once: on exhaustion, an error or cancellation
330
+ # mid-stream, ``close()`` (which ``with`` / ``async with`` exit calls), or
331
+ # garbage collection of an abandoned stream. Mirrors struct-sdk-typescript's
332
+ # ``instrumentRawStream``. No ambient context is held across the caller's
333
+ # iteration: the span was detached when ``create()`` returned.
334
+
335
+ _ITERATOR_SLOT = "_iterator"
336
+ """The attribute anthropic's ``Stream`` / ``AsyncStream`` iterate (``__iter__``,
337
+ ``__next__`` and their async twins all read it). A stream without it keeps the
338
+ pre-0.2.32 behaviour: the span ends when ``create()`` returns."""
339
+
340
+
341
+ _TELEMETRY_SLOT = "_struct_raw_stream_telemetry"
342
+ """Where a hooked stream keeps its ``_RawStreamTelemetry``, read by the
343
+ class-level close hook. The stream holds the telemetry, never the reverse."""
344
+
345
+
346
+ def _close_hook(original: Any) -> Any:
347
+ """Stream / AsyncStream ``close``, hooked once at the class: a stream with
348
+ telemetry ends its span after the provider's close (with the error, if it
349
+ raised); any other stream just closes. The method stays bound to the
350
+ stream, so cleanup the host defers (an ExitStack callback) keeps it alive."""
351
+ if inspect.iscoroutinefunction(original):
352
+ @functools.wraps(original)
353
+ async def aclose(self: Any) -> Any:
354
+ telemetry = _attached_telemetry(self)
355
+ try:
356
+ result = await original(self)
357
+ except BaseException as e:
358
+ if telemetry is not None:
359
+ telemetry.end((type(e), e, None))
360
+ raise
361
+ if telemetry is not None:
362
+ telemetry.end(())
363
+ return result
364
+ hooked: Any = aclose
365
+ else:
366
+ @functools.wraps(original)
367
+ def close(self: Any) -> Any:
368
+ telemetry = _attached_telemetry(self)
369
+ try:
370
+ result = original(self)
371
+ except BaseException as e:
372
+ if telemetry is not None:
373
+ telemetry.end((type(e), e, None))
374
+ raise
375
+ if telemetry is not None:
376
+ telemetry.end(())
377
+ return result
378
+ hooked = close
379
+ hooked.__struct_close_hook__ = True
380
+ hooked.__struct_original__ = original
381
+ return hooked
382
+
383
+
384
+ def _attached_telemetry(stream: Any) -> Any:
385
+ try:
386
+ return vars(stream).get(_TELEMETRY_SLOT)
387
+ except BaseException: # noqa: BLE001 — never fail the host's close
388
+ return None
389
+
390
+
391
+ _TEXT_BUDGET = _MAX_FIELD_SIZE + 1
392
+ """Characters of text or reasoning one content block retains: one over the
393
+ exported field cap, so truncation (and its marker) still applies downstream."""
394
+
395
+
396
+ class _StreamAccumulator:
397
+ """Rebuilds the final message from raw events, for ``_set_response_attrs``.
398
+
399
+ Text, reasoning and tool arguments are kept only when content is captured;
400
+ tool-use ids and names always are (tool-call linkage is structural).
401
+ """
402
+
403
+ def __init__(self, keep_text: bool) -> None:
404
+ self._keep_text = keep_text
405
+ self._seen = False
406
+ self.id: Optional[str] = None
407
+ self.model: Optional[str] = None
408
+ self.stop_reason: Optional[str] = None
409
+ self._usage: dict[str, int] = {}
410
+ self._blocks: dict[int, dict[str, Any]] = {}
411
+
412
+ def _take_usage(self, usage: Any) -> None:
413
+ for key in ("input_tokens", "output_tokens", "cache_read_input_tokens",
414
+ "cache_creation_input_tokens"):
415
+ value = getattr(usage, key, None)
416
+ if isinstance(value, int):
417
+ self._usage[key] = value
418
+
419
+ def add(self, event: Any) -> None:
420
+ kind = getattr(event, "type", None)
421
+ index = getattr(event, "index", None)
422
+ if kind == "message_start":
423
+ message = getattr(event, "message", None)
424
+ self._seen = True
425
+ self.id = getattr(message, "id", None)
426
+ self.model = getattr(message, "model", None)
427
+ self._take_usage(getattr(message, "usage", None))
428
+ elif kind == "message_delta":
429
+ self.stop_reason = getattr(getattr(event, "delta", None), "stop_reason", None) or self.stop_reason
430
+ # Cumulative counts: output always, input/cache when the API sends them.
431
+ self._take_usage(getattr(event, "usage", None))
432
+ elif kind == "content_block_start" and isinstance(index, int):
433
+ block = getattr(event, "content_block", None)
434
+ block_type = getattr(block, "type", None)
435
+ entry: dict[str, Any] = {"type": block_type}
436
+ # Text pieces are collected in lists and joined once in
437
+ # final_message: += on a stored str copies it on every delta.
438
+ if block_type == "tool_use":
439
+ # Linkage needs only the id and name; the arguments are content.
440
+ entry.update(id=getattr(block, "id", None), name=getattr(block, "name", None))
441
+ if self._keep_text:
442
+ entry["json"] = []
443
+ elif block_type == "text" and self._keep_text:
444
+ entry.update(text=[], budget=_TEXT_BUDGET)
445
+ self._keep(entry, "text", getattr(block, "text", "") or "")
446
+ elif block_type == "thinking" and self._keep_text:
447
+ entry.update(thinking=[], budget=_TEXT_BUDGET)
448
+ self._keep(entry, "thinking", getattr(block, "thinking", "") or "")
449
+ self._blocks[index] = entry
450
+ elif kind == "content_block_delta" and isinstance(index, int):
451
+ open_block = self._blocks.get(index)
452
+ delta = getattr(event, "delta", None)
453
+ delta_type = getattr(delta, "type", None)
454
+ if open_block is None:
455
+ return
456
+ if delta_type == "input_json_delta" and "json" in open_block:
457
+ open_block["json"].append(getattr(delta, "partial_json", "") or "")
458
+ elif delta_type == "text_delta" and "text" in open_block:
459
+ self._keep(open_block, "text", getattr(delta, "text", "") or "")
460
+ elif delta_type == "thinking_delta" and "thinking" in open_block:
461
+ self._keep(open_block, "thinking", getattr(delta, "thinking", "") or "")
462
+
463
+ @staticmethod
464
+ def _keep(block: dict[str, Any], key: str, piece: str) -> None:
465
+ """Text and reasoning are exported truncated to the field cap, so each block
466
+ retains only that much (its own budget: a long reasoning block never
467
+ crowds out the answer); tool arguments are always kept whole."""
468
+ if block["budget"] <= 0 or not piece:
469
+ return
470
+ block[key].append(piece[: block["budget"]])
471
+ block["budget"] -= len(piece)
472
+
473
+ def final_message(self) -> Any:
474
+ if not self._seen:
475
+ return None
476
+ content = []
477
+ for index in sorted(self._blocks):
478
+ entry = {key: "".join(value) if isinstance(value, list) else value
479
+ for key, value in self._blocks[index].items() if key != "budget"}
480
+ raw = entry.pop("json", None)
481
+ if raw is not None:
482
+ try:
483
+ entry["input"] = json.loads(raw) if raw else {}
484
+ except (ValueError, RecursionError):
485
+ entry["input"] = raw # malformed or too deep: keep the raw arguments
486
+ content.append(SimpleNamespace(**entry))
487
+ return SimpleNamespace(
488
+ id=self.id, model=self.model, stop_reason=self.stop_reason, content=content,
489
+ usage=SimpleNamespace(**self._usage),
490
+ )
491
+
492
+
493
+ def _ambient_pending_tool_calls() -> dict[str, list[str]]:
494
+ """The current context's tool-use queue, created (as a non-streaming call does)
495
+ when no agent scope set one."""
496
+ from struct_sdk.core import _pending_tool_calls
497
+ pending = _pending_tool_calls.get(None)
498
+ if pending is None:
499
+ pending = {}
500
+ _pending_tool_calls.set(pending)
501
+ return pending
502
+
503
+
504
+ class _RawStreamTelemetry:
505
+ """Owns the ``chat`` span of one raw stream; ``end`` is idempotent and
506
+ thread-safe (garbage collection may finalize on any thread)."""
507
+
508
+ def __init__(self, span: trace.Span, sdk: "StructSDK", model: str, otel_logger: Any,
509
+ provider: str) -> None:
510
+ # The creating call's tool-use queue, created here when none exists (as the
511
+ # non-streaming path does on response): child tasks that pull events, and
512
+ # whatever context finally ends the stream, all feed THIS queue.
513
+ pending = _ambient_pending_tool_calls()
514
+ # The creating call's context: the stream may end (or be collected) in
515
+ # another request's, whose session must not label this one's events.
516
+ self._origin: Optional[contextvars.Context] = contextvars.copy_context()
517
+ self._span, self._sdk, self._model = span, sdk, model
518
+ self._otel_logger, self._provider = otel_logger, provider
519
+ self._acc: Optional[_StreamAccumulator] = _StreamAccumulator(
520
+ keep_text=bool(sdk.emit_events or sdk.emit_span_content))
521
+ self._pending_tool_calls: Optional[dict[str, list[str]]] = pending
522
+ self._lock = threading.Lock()
523
+ # Armed only once every hook is in place: a half-installed stream leaves
524
+ # the span to create(), and these hooks must then never end it again.
525
+ self.armed = False
526
+ self._ended = False
527
+
528
+ def release(self) -> None:
529
+ """Drop what pins the creating request (its context and tool queue)."""
530
+ self._origin = None
531
+ self._pending_tool_calls = None
532
+ self._acc = None
533
+
534
+ def observe(self, event: Any) -> None:
535
+ acc = self._acc
536
+ if acc is not None:
537
+ # Runs inside the host's next(): contained like every host-path satellite.
538
+ _contain(lambda: acc.add(event), "anthropic.raw_stream.accumulate")
539
+ # The response is complete at message_stop: end now, so a consumer that
540
+ # acts on the terminal event (e.g. runs the tools) already has the
541
+ # tool-use ids. Contained: the event's own getters may raise.
542
+ def end_if_complete() -> None:
543
+ if getattr(event, "type", None) == "message_stop":
544
+ self.end(())
545
+
546
+ _contain(end_if_complete, "anthropic.raw_stream.terminal")
547
+
548
+ def end(self, exc: tuple) -> None:
549
+ with self._lock:
550
+ if self._ended or not self.armed:
551
+ return
552
+ self._ended = True
553
+ acc, self._acc = self._acc, None # release what was accumulated
554
+ pending: Any = self._pending_tool_calls # captured at create; always set here
555
+ final: Any = None
556
+
557
+ def build() -> None:
558
+ nonlocal final
559
+ if acc is not None:
560
+ final = acc.final_message()
561
+
562
+ # Its own guard: a fault rebuilding the message loses response attributes,
563
+ # never the span's end.
564
+ _contain(build, "anthropic.raw_stream.final_message")
565
+ origin = self._origin or contextvars.copy_context() # set until the one end
566
+ try:
567
+ _contain(
568
+ lambda: origin.run(
569
+ _finish_stream_span_with_final,
570
+ self._span, final, self._sdk, self._model, self._otel_logger, exc,
571
+ self._provider, pending_tool_calls=pending,
572
+ ),
573
+ "anthropic.raw_stream.end",
574
+ )
575
+ finally:
576
+ # Ended: drop what pins the creating request (its whole context and
577
+ # tool queue) for as long as the host keeps the stream object.
578
+ self._origin = None
579
+ self._pending_tool_calls = None
580
+
581
+
582
+ class _ObservedEvents:
583
+ """The stream's iterator, observed: every event passes through unchanged."""
584
+
585
+ __slots__ = ("_inner", "_telemetry")
586
+
587
+ def __init__(self, inner: Any, telemetry: _RawStreamTelemetry) -> None:
588
+ self._inner, self._telemetry = inner, telemetry
589
+
590
+ def __iter__(self) -> "_ObservedEvents":
591
+ return self
592
+
593
+ def __del__(self) -> None:
594
+ # The stream was collected without ending: GC ends the span. (No
595
+ # weakref.finalize: its first use registers a process exit hook.)
596
+ try:
597
+ self._telemetry.end(())
598
+ except BaseException: # noqa: BLE001 — never raise out of a finalizer
599
+ pass
600
+
601
+ def __next__(self) -> Any:
602
+ try:
603
+ event = next(self._inner)
604
+ except StopIteration:
605
+ self._telemetry.end(())
606
+ raise
607
+ except BaseException as e:
608
+ # No traceback read here: a host exception's __traceback__ getter
609
+ # can raise, and nothing may replace the host's error.
610
+ self._telemetry.end((type(e), e, None))
611
+ raise
612
+ self._telemetry.observe(event)
613
+ return event
614
+
615
+
616
+ class _ObservedAsyncEvents:
617
+ __slots__ = ("_inner", "_telemetry")
618
+
619
+ def __init__(self, inner: Any, telemetry: _RawStreamTelemetry) -> None:
620
+ self._inner, self._telemetry = inner, telemetry
621
+
622
+ def __aiter__(self) -> "_ObservedAsyncEvents":
623
+ return self
624
+
625
+ def __del__(self) -> None:
626
+ try:
627
+ self._telemetry.end(())
628
+ except BaseException: # noqa: BLE001 — never raise out of a finalizer
629
+ pass
630
+
631
+ async def __anext__(self) -> Any:
632
+ try:
633
+ event = await self._inner.__anext__()
634
+ except StopAsyncIteration:
635
+ self._telemetry.end(())
636
+ raise
637
+ except BaseException as e:
638
+ self._telemetry.end((type(e), e, None))
639
+ raise
640
+ self._telemetry.observe(event)
641
+ return event
642
+
643
+
644
+ def _instrument_raw_stream(
645
+ stream: Any, span: trace.Span, sdk: "StructSDK", model: str, otel_logger: Any, provider: str,
646
+ ) -> bool:
647
+ """Make ``stream`` end ``span`` when it ends. False when it can't be hooked
648
+ (the caller then ends the span now, as before)."""
649
+ # Another tracer may hand back a proxy around the provider's stream (ddtrace's
650
+ # wrapt TracedStream): hook the stream it wraps, which the proxy delegates to.
651
+ for _ in range(8):
652
+ if getattr(type(stream), "close", None) is not None or not hasattr(stream, "__wrapped__"):
653
+ break
654
+ stream = stream.__wrapped__
655
+ inner = getattr(stream, _ITERATOR_SLOT, None)
656
+ class_close = getattr(type(stream), "close", None)
657
+ if inner is None or class_close is None:
658
+ return False
659
+ if "close" in getattr(stream, "__dict__", {}):
660
+ # Someone already replaced close on this instance (the host, another
661
+ # tracer): calling the class's would bypass their cleanup. Decline.
662
+ return False
663
+ if not getattr(class_close, "__struct_close_hook__", False):
664
+ # The class overrides close below the hooked Stream / AsyncStream: its
665
+ # close would never reach the hook. Decline.
666
+ return False
667
+ telemetry = _RawStreamTelemetry(span, sdk, model, otel_logger, provider)
668
+ inner_async = hasattr(inner, "__anext__")
669
+ observed: Any = _ObservedAsyncEvents(inner, telemetry) if inner_async else _ObservedEvents(inner, telemetry)
670
+ # close stays the class's own method (the class-level hook reads this slot),
671
+ # so a host that keeps ``stream.close`` for later cleanup keeps the stream.
672
+ setattr(stream, _TELEMETRY_SLOT, telemetry)
673
+ try:
674
+ setattr(stream, _ITERATOR_SLOT, observed)
675
+ except BaseException:
676
+ # Half-installed: detach the telemetry, and let go of the creating
677
+ # request's context; create() ends the span itself.
678
+ _contain(lambda: delattr(stream, _TELEMETRY_SLOT), "anthropic.raw_stream.detach")
679
+ telemetry.release()
680
+ raise
681
+ telemetry.armed = True
682
+ return True
683
+
684
+
295
685
  def _wrap_create(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logger: Any, is_async: bool) -> Any:
296
686
  # All host-boundary mechanics (prime / error-forward / complete, each
297
687
  # BaseException-contained) live in the shared drivers — the choke point.
@@ -367,17 +757,17 @@ async def _resolve_final_message_async(stream: Any) -> Any:
367
757
 
368
758
  def _finish_stream_span_with_final(
369
759
  span: trace.Span, final_msg: Any, sdk: "StructSDK", model: str, otel_logger: Any, exc: tuple,
370
- provider: str = "anthropic",
760
+ provider: str = "anthropic", *, pending_tool_calls: Any = _AMBIENT,
371
761
  ) -> None:
372
762
  """End the streaming ``chat`` span EXACTLY ONCE on block exit: populate
373
763
  response attributes from the already-resolved final message (best-effort —
374
764
  the stream may have been left partially consumed), set status, and end. All
375
- _safe-wrapped so a broken stream can never fault the host's ``with`` block."""
376
- from struct_sdk.core import _safe
765
+ contained so a broken stream can never fault the host's ``with`` block."""
377
766
 
378
767
  def body() -> None:
379
768
  if final_msg is not None:
380
- _set_response_attrs(span, sdk, model, final_msg, otel_logger, provider)
769
+ _set_response_attrs(span, sdk, model, final_msg, otel_logger, provider,
770
+ pending_tool_calls=pending_tool_calls)
381
771
  if exc and exc[0] is not None:
382
772
  span.set_attribute("error.type", exc[0].__name__)
383
773
  span.set_status(StatusCode.ERROR, str(exc[1]))
@@ -385,8 +775,10 @@ def _finish_stream_span_with_final(
385
775
  else:
386
776
  span.set_status(StatusCode.OK)
387
777
 
388
- _safe(body, site="anthropic.stream.finish")
389
- _safe(span.end, site="anthropic.stream.end")
778
+ # Contained separately, BaseException-safe (a failing log handler included):
779
+ # whatever happens to the attributes, the span still ends.
780
+ _contain(body, "anthropic.stream.finish")
781
+ _contain(span.end, "anthropic.stream.end")
390
782
 
391
783
 
392
784
  def _finish_stream_span(
@@ -710,15 +1102,23 @@ def _iter_tool_uses(content_blocks: Any) -> list[tuple[str, str]]:
710
1102
  return result
711
1103
 
712
1104
 
713
- def _record_pending_tool_calls(content_blocks: Any) -> None:
1105
+ def _record_pending_tool_calls(content_blocks: Any, into: Any = _AMBIENT) -> None:
714
1106
  """Push every tool_use (name, id) from the response into the SDK contextvar.
715
1107
 
1108
+ ``into`` selects a captured queue: the creating agent scope's dictionary or
1109
+ the ambient dictionary created for a raw stream outside an agent. The default
1110
+ uses the current context's queue; passing None skips recording.
1111
+
716
1112
  Always runs (not gated on content-capture mode) — the linkage between a
717
1113
  chat span and its child execute_tool spans is structural, not content.
718
1114
  """
719
1115
  from struct_sdk.core import _pending_tool_calls
720
1116
  pairs = _iter_tool_uses(content_blocks)
721
- if not pairs:
1117
+ if not pairs or into is None:
1118
+ return
1119
+ if into is not _AMBIENT:
1120
+ for name, tool_id in pairs:
1121
+ into.setdefault(name, []).append(tool_id)
722
1122
  return
723
1123
  pending = _pending_tool_calls.get()
724
1124
  if pending is None:
@@ -733,7 +1133,7 @@ def _record_pending_tool_calls(content_blocks: Any) -> None:
733
1133
 
734
1134
  def _set_response_attrs(
735
1135
  span: trace.Span, sdk: StructSDK, model: str, response: Any, otel_logger: Any = None,
736
- provider: str = "anthropic",
1136
+ provider: str = "anthropic", *, pending_tool_calls: Any = _AMBIENT,
737
1137
  ) -> None:
738
1138
  if not hasattr(response, "usage"):
739
1139
  return
@@ -764,7 +1164,7 @@ def _set_response_attrs(
764
1164
  if hasattr(response, "content"):
765
1165
  # Populate pending tool_use ids for @struct.tool() auto-linkage.
766
1166
  # Runs unconditionally — independent of content-capture mode.
767
- _record_pending_tool_calls(response.content)
1167
+ _record_pending_tool_calls(response.content, into=pending_tool_calls)
768
1168
 
769
1169
  finish_reason = getattr(response, "stop_reason", None)
770
1170
 
@@ -16,7 +16,7 @@ import json
16
16
  import logging
17
17
  import threading
18
18
  from enum import Enum
19
- from typing import Any, Callable, Optional
19
+ from typing import Any, Callable, Mapping, Optional, TypeGuard, Union
20
20
  from importlib.metadata import version as _pkg_version
21
21
 
22
22
  from opentelemetry import context as _otel_context
@@ -28,9 +28,10 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExport
28
28
  from opentelemetry.sdk._logs import LoggerProvider
29
29
  from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
30
30
  from opentelemetry.sdk.resources import Resource
31
- from opentelemetry.sdk.trace import TracerProvider
31
+ from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
32
32
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
33
33
  from opentelemetry.trace import StatusCode
34
+ from opentelemetry.util.types import AttributeValue
34
35
 
35
36
  logger = logging.getLogger("struct_sdk")
36
37
  # A library must not configure logging on the host's behalf. Attach a NullHandler
@@ -68,6 +69,72 @@ _current_agent_span: contextvars.ContextVar[Optional[trace.Span]] = contextvars.
68
69
  # invoke_agent span and instead parents its children to this span.
69
70
  _manual_agent_active: contextvars.ContextVar[Optional[trace.Span]] = contextvars.ContextVar("_manual_agent_active", default=None)
70
71
 
72
+ AgentMetadataValue = Union[
73
+ str, bool, int, float,
74
+ list[str], list[bool], list[int], list[float],
75
+ tuple[str, ...], tuple[bool, ...], tuple[int, ...], tuple[float, ...],
76
+ None,
77
+ ]
78
+ """What ``struct.agent(metadata=...)`` accepts per key (SDK-PY-25): a scalar, a list or
79
+ tuple of one scalar type, or None (inherits the enclosing value)."""
80
+
81
+ # The enclosing struct.agent() scopes' metadata, merged (inner wins). The
82
+ # _MetadataSpanProcessor stamps it on every span started on Struct's provider
83
+ # inside the scope ([SDK-PY-25]). Never Baggage: it is propagated in-process
84
+ # only and exported as span attributes, never sent on outgoing request headers.
85
+ _current_metadata: contextvars.ContextVar[Optional["_MetadataFrame"]] = contextvars.ContextVar("_current_metadata", default=None)
86
+
87
+
88
+ class _MetadataFrame:
89
+ """One struct.agent() scope's OWN metadata entries, linked to its nearest open
90
+ enclosing scope. Teardown marks it closed, so a scope ended on another task
91
+ (where the contextvar reset can't run) stops applying in every context that
92
+ still points at it, including through open scopes nested inside it: lookups
93
+ merge only the open frames, outer to inner."""
94
+
95
+ __slots__ = ("metadata", "parent", "closed")
96
+
97
+ def __init__(self, metadata: Mapping[str, AttributeValue], parent: Optional["_MetadataFrame"]) -> None:
98
+ self.metadata = metadata
99
+ self.parent = parent
100
+ self.closed = False
101
+
102
+
103
+ def _open_frame(frame: Optional[_MetadataFrame]) -> Optional[_MetadataFrame]:
104
+ while frame is not None and frame.closed:
105
+ frame = frame.parent
106
+ return frame
107
+
108
+
109
+ def _compacted(frame: Optional[_MetadataFrame]) -> Optional[_MetadataFrame]:
110
+ """The nearest open frame, with every open ancestor relinked past closed ones
111
+ (overlapping task handoffs close scopes out of order). Run on scope entry and
112
+ on lookup, so the chain stays bounded even when no span processor runs (an
113
+ unsampled trace)."""
114
+ head = _open_frame(frame)
115
+ node = head
116
+ while node is not None:
117
+ node.parent = _open_frame(node.parent)
118
+ node = node.parent
119
+ return head
120
+
121
+
122
+ def _active_metadata() -> Mapping[str, AttributeValue]:
123
+ chain: list[Mapping[str, AttributeValue]] = []
124
+ current = _current_metadata.get(None)
125
+ frame = _compacted(current)
126
+ if frame is not current:
127
+ # Keep the compacted head, so closed scopes are released rather than
128
+ # rescanned by every later span (open scopes keep their head identity).
129
+ _current_metadata.set(frame)
130
+ while frame is not None:
131
+ chain.append(frame.metadata)
132
+ frame = frame.parent
133
+ merged: dict[str, AttributeValue] = {}
134
+ for metadata in reversed(chain): # outer first: inner entries win
135
+ merged.update(metadata)
136
+ return merged
137
+
71
138
  # Pending tool_use ids keyed by tool name (FIFO per name).
72
139
  # Populated by the Anthropic monkey-patch when a chat response arrives with
73
140
  # tool_use blocks, consumed by @struct.tool() / struct.tool(...) when the
@@ -227,6 +294,21 @@ def _safe(fn: Callable[[], None], *, site: str) -> None:
227
294
  logger.debug("Struct SDK suppressed exception at %s", site, exc_info=True)
228
295
 
229
296
 
297
+ def _contain_logged(fn: Callable[[], None], *, site: str) -> None:
298
+ """``_safe`` for hooks that run inside the host's own calls (a span
299
+ processor's ``on_start``): contains BaseException and a failing log handler
300
+ too, with the same first-failure-per-site traceback policy (SDK-PY-19)."""
301
+ try:
302
+ fn()
303
+ except BaseException: # noqa: BLE001 — the host's call must always proceed
304
+ try:
305
+ first = site not in _first_failure_logged
306
+ _first_failure_logged.add(site)
307
+ logger.debug("Struct SDK suppressed exception at %s", site, exc_info=True if first else None)
308
+ except BaseException: # noqa: BLE001
309
+ pass
310
+
311
+
230
312
  class StructSDK:
231
313
  """Global SDK instance. Call init() once at startup."""
232
314
 
@@ -331,6 +413,7 @@ class StructSDK:
331
413
  }
332
414
  )
333
415
  self._tracer_provider = TracerProvider(resource=resource)
416
+ self._tracer_provider.add_span_processor(_MetadataSpanProcessor())
334
417
  self._tracer_provider.add_span_processor(span_processor)
335
418
 
336
419
  # --- LoggerProvider (for gen_ai message events) ---
@@ -434,7 +517,7 @@ class StructSDK:
434
517
  session_id: Optional[str] = None,
435
518
  agent_id: Optional[str] = None,
436
519
  version: Optional[str] = None,
437
- metadata: Optional[dict[str, str]] = None,
520
+ metadata: Optional[Mapping[str, AgentMetadataValue]] = None,
438
521
  ) -> Any:
439
522
  """Mark a function or block as an agent session.
440
523
 
@@ -565,10 +648,85 @@ class _NoOpContext:
565
648
  # Agent context — invoke_agent span (GenAI spec)
566
649
  # ---------------------------------------------------------------------------
567
650
 
651
+ _ATTRIBUTE_SCALARS = (str, bool, int, float)
652
+ # Inherited metadata keys stamped per span, and the attribute slots left free for
653
+ # the span's own later fields (a model call's response and usage).
654
+ MAX_METADATA_KEYS = 32
655
+ METADATA_HEADROOM = 32
656
+ _INT64_MIN, _INT64_MAX = -(2**63), 2**63 - 1
657
+
658
+
659
+ def _encodable(item: Any) -> bool:
660
+ """What OTLP's protobuf can encode: integers within signed 64 bits, and
661
+ strings that are valid UTF-8 (a lone surrogate, as ``json.loads`` can
662
+ produce, fails at export with an ERROR per span)."""
663
+ if isinstance(item, str):
664
+ try:
665
+ item.encode("utf-8")
666
+ except UnicodeEncodeError:
667
+ return False
668
+ return True
669
+ return not isinstance(item, int) or isinstance(item, bool) or _INT64_MIN <= item <= _INT64_MAX
670
+
671
+
672
+ def _is_attribute_value(value: Any) -> TypeGuard[AttributeValue]:
673
+ """The metadata values propagated to child spans: str, bool, int or float, or a
674
+ list/tuple (empty, or all of one of those types). A subset of what OTel itself
675
+ accepts (it also takes other sequences and None items); anything else is
676
+ skipped silently and inherits the enclosing value."""
677
+ if isinstance(value, _ATTRIBUTE_SCALARS):
678
+ return _encodable(value)
679
+ if isinstance(value, (list, tuple)):
680
+ if not value:
681
+ return True
682
+ kind = type(value[0])
683
+ return kind in _ATTRIBUTE_SCALARS and all(type(item) is kind and _encodable(item) for item in value)
684
+ return False
685
+
686
+
687
+ class _MetadataSpanProcessor(SpanProcessor):
688
+ """Stamps the enclosing agents' ``struct.metadata.*`` on each span as it
689
+ starts, so chat, tool and the host's own spans carry them without a join
690
+ back to ``invoke_agent``. An attribute the span already has at start wins.
691
+ Runs inside ``start_span``, where the OTel SDK does not guard processors,
692
+ so nothing may escape."""
693
+
694
+ def on_start(self, span: Any, parent_context: Optional[_OtelContext] = None) -> None:
695
+ def stamp() -> None:
696
+ metadata = _active_metadata() # validated once, at scope entry
697
+ if not metadata:
698
+ return
699
+ # A snapshot: the span's attributes are a live view.
700
+ existing = set(span.attributes or ())
701
+ # OTel evicts the OLDEST attribute past the span's limit, so inherited
702
+ # keys only fill room left after the headroom (SDK-PY-25).
703
+ limit = getattr(getattr(span, "_limits", None), "max_span_attributes", None)
704
+ budget = MAX_METADATA_KEYS if limit is None else min(
705
+ MAX_METADATA_KEYS, limit - len(existing) - METADATA_HEADROOM)
706
+ for key, value in metadata.items():
707
+ if budget <= 0:
708
+ break
709
+ attribute = f"struct.metadata.{key}"
710
+ if attribute not in existing:
711
+ span.set_attribute(attribute, value)
712
+ budget -= 1
713
+
714
+ _contain_logged(stamp, site="metadata.on_start")
715
+
716
+ def on_end(self, span: Any) -> None:
717
+ pass
718
+
719
+ def shutdown(self) -> None:
720
+ pass
721
+
722
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
723
+ return True
724
+
725
+
568
726
  class _AgentContext:
569
727
  """Creates an ``invoke_agent {name}`` span per the OTel GenAI spec."""
570
728
 
571
- def __init__(self, sdk: StructSDK, *, name: Optional[str] = None, session_id: Optional[str] = None, agent_id: Optional[str] = None, version: Optional[str] = None, metadata: Optional[dict[str, str]] = None):
729
+ def __init__(self, sdk: StructSDK, *, name: Optional[str] = None, session_id: Optional[str] = None, agent_id: Optional[str] = None, version: Optional[str] = None, metadata: Optional[Mapping[str, AgentMetadataValue]] = None):
572
730
  self._sdk = sdk
573
731
  self._name = name
574
732
  # _explicit_session_id is the CALLER-SUPPLIED value (may be None).
@@ -589,6 +747,8 @@ class _AgentContext:
589
747
  self._agent_span_token: Optional[contextvars.Token[Optional[trace.Span]]] = None
590
748
  self._pending_tool_token: Optional[contextvars.Token[Optional[dict[str, list[str]]]]] = None
591
749
  self._manual_token: Optional[contextvars.Token[Optional[trace.Span]]] = None
750
+ self._metadata_token: Optional[contextvars.Token[Optional[_MetadataFrame]]] = None
751
+ self._metadata_frame: Optional[_MetadataFrame] = None
592
752
 
593
753
  def __call__(self, fn: Any) -> Any:
594
754
  """Use as decorator."""
@@ -736,10 +896,17 @@ class _AgentContext:
736
896
  # enclosing span (break_out was False). Records the cross-session
737
897
  # parent. Same-session inline subagents fall through with nothing.
738
898
  self._span.set_attribute("struct.agent.parent_session_id", enclosing_session_id)
739
- # Custom metadata
740
- if self._metadata:
741
- for key, value in self._metadata.items():
742
- self._span.set_attribute(f"struct.metadata.{key}", value)
899
+ # Custom metadata, snapshotted once (lists become tuples: a caller's
900
+ # list may change after the scope starts) and validated once, for
901
+ # this span and its children alike. Values OTel can't store (None
902
+ # included) are dropped, so they inherit the enclosing value.
903
+ own: dict[str, AttributeValue] = {}
904
+ for key, supplied in (self._metadata or {}).items():
905
+ snapshot = tuple(supplied) if isinstance(supplied, list) else supplied
906
+ if isinstance(key, str) and _encodable(key) and _is_attribute_value(snapshot):
907
+ own[key] = snapshot
908
+ for key, value in own.items():
909
+ self._span.set_attribute(f"struct.metadata.{key}", value)
743
910
 
744
911
  # Make this the current OTel span so children (incl. customer raw
745
912
  # spans) nest under it. Detach is TASK-SAFE on exit (see
@@ -761,6 +928,13 @@ class _AgentContext:
761
928
  # the same top-level chain (parent_run_id is None) and parent its
762
929
  # children under this span instead.
763
930
  self._manual_token = _manual_agent_active.set(self._span)
931
+ if self._metadata:
932
+ # Every span started inside this scope gets the merged metadata
933
+ # (the span above got the enclosing scope's from the processor,
934
+ # then its own keys on top).
935
+ # Linked to the nearest OPEN scope: closed frames never pile up.
936
+ self._metadata_frame = _MetadataFrame(own, _compacted(_current_metadata.get(None)))
937
+ self._metadata_token = _current_metadata.set(self._metadata_frame)
764
938
  started = True
765
939
 
766
940
  _safe(body, site="agent.start_span")
@@ -769,6 +943,14 @@ class _AgentContext:
769
943
  # _end_span see a clean "no telemetry" view: tokens are reset
770
944
  # best-effort, the OTel context stack is popped if it was pushed,
771
945
  # the span is ended if it was started, and references are dropped.
946
+ if self._metadata_frame is not None:
947
+ self._metadata_frame.closed = True
948
+ self._metadata_frame = None
949
+ metadata_tok = self._metadata_token
950
+ if metadata_tok is not None:
951
+ _safe(lambda: _current_metadata.reset(metadata_tok),
952
+ site="agent.start_span.reset_metadata")
953
+ self._metadata_token = None
772
954
  manual_tok = self._manual_token
773
955
  if manual_tok is not None:
774
956
  _safe(lambda: _manual_agent_active.reset(manual_tok),
@@ -808,6 +990,17 @@ class _AgentContext:
808
990
  def _end_span(self, exc_val: Any = None) -> None:
809
991
  # Contextvar resets must always run — they're cheap, can't fault on the
810
992
  # span, and leaving them set leaks session context into the caller.
993
+ frame = self._metadata_frame
994
+ if frame is not None:
995
+ frame.closed = True # stops applying even where the reset can't run
996
+ metadata_tok = self._metadata_token
997
+ if metadata_tok is not None:
998
+ # Only while this scope is still the head: when scopes close out of
999
+ # order on one task (async generators), resetting would discard a
1000
+ # still-open inner scope; compaction skips this closed frame instead.
1001
+ _safe(lambda: _current_metadata.reset(metadata_tok)
1002
+ if _current_metadata.get(None) is frame else None,
1003
+ site="agent.end_span.reset_metadata")
811
1004
  manual_tok = self._manual_token
812
1005
  if manual_tok is not None:
813
1006
  _safe(lambda: _manual_agent_active.reset(manual_tok),
File without changes
File without changes
File without changes