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.
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/PKG-INFO +1 -1
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/pyproject.toml +4 -1
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/src/struct_sdk/_genai_content.py +15 -9
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/src/struct_sdk/anthropic.py +411 -11
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/src/struct_sdk/core.py +201 -8
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/.gitignore +0 -0
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/LICENSE +0 -0
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/README.md +0 -0
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/src/struct_sdk/langchain.py +0 -0
- {struct_sdk-0.2.30 → struct_sdk-0.2.32}/src/struct_sdk/openai.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: struct-sdk
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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[
|
|
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[
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|