struct-sdk 0.2.8__tar.gz → 0.2.10__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.8 → struct_sdk-0.2.10}/PKG-INFO +3 -3
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/pyproject.toml +5 -3
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/src/struct_sdk/anthropic.py +150 -32
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/src/struct_sdk/core.py +145 -57
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/src/struct_sdk/langchain.py +87 -16
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/.gitignore +0 -0
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/LICENSE +0 -0
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/README.md +0 -0
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.2.8 → struct_sdk-0.2.10}/src/struct_sdk/claude_agent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: struct-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
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
|
|
@@ -29,10 +29,10 @@ Requires-Dist: anthropic>=0.30.0; extra == 'anthropic'
|
|
|
29
29
|
Provides-Extra: claude-agent-sdk
|
|
30
30
|
Requires-Dist: claude-agent-sdk>=0.1.59; extra == 'claude-agent-sdk'
|
|
31
31
|
Provides-Extra: demo
|
|
32
|
-
Requires-Dist: langchain-anthropic>=
|
|
32
|
+
Requires-Dist: langchain-anthropic>=1.4.6; extra == 'demo'
|
|
33
33
|
Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
|
|
34
34
|
Requires-Dist: langchain-openai>=0.2.0; extra == 'demo'
|
|
35
|
-
Requires-Dist: langchain>=1.3.
|
|
35
|
+
Requires-Dist: langchain>=1.3.9; extra == 'demo'
|
|
36
36
|
Requires-Dist: langgraph>=0.2.0; extra == 'demo'
|
|
37
37
|
Requires-Dist: python-dotenv>=1.0.0; extra == 'demo'
|
|
38
38
|
Provides-Extra: dev
|
|
@@ -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.10"
|
|
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"
|
|
@@ -40,10 +40,10 @@ anthropic = ["anthropic>=0.30.0"]
|
|
|
40
40
|
claude-agent-sdk = ["claude-agent-sdk>=0.1.59"]
|
|
41
41
|
langchain = ["langchain-core>=1.3.3"]
|
|
42
42
|
demo = [
|
|
43
|
-
"langchain>=1.3.
|
|
43
|
+
"langchain>=1.3.9",
|
|
44
44
|
"langchain-core>=1.3.3",
|
|
45
45
|
"langchain-openai>=0.2.0",
|
|
46
|
-
"langchain-anthropic>=
|
|
46
|
+
"langchain-anthropic>=1.4.6",
|
|
47
47
|
"langgraph>=0.2.0",
|
|
48
48
|
"python-dotenv>=1.0.0",
|
|
49
49
|
]
|
|
@@ -70,6 +70,8 @@ override-dependencies = [
|
|
|
70
70
|
"langchain-text-splitters>=1.1.2",
|
|
71
71
|
"python-multipart>=0.0.30",
|
|
72
72
|
"starlette>=1.3.1",
|
|
73
|
+
"langsmith>=0.8.18",
|
|
74
|
+
"pydantic-settings>=2.14.2",
|
|
73
75
|
]
|
|
74
76
|
|
|
75
77
|
[tool.pytest.ini_options]
|
|
@@ -290,28 +290,149 @@ def _wrap_create(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
290
290
|
# messages.stream — context manager wrapping
|
|
291
291
|
# ---------------------------------------------------------------------------
|
|
292
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
|
+
|
|
293
409
|
def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logger: Any, is_async: bool) -> Any:
|
|
294
410
|
"""Wrap messages.stream() to trace the streaming context manager.
|
|
295
411
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
accumulated final message.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
``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.)
|
|
304
419
|
"""
|
|
305
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``.
|
|
306
427
|
@functools.wraps(original)
|
|
307
|
-
|
|
428
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
308
429
|
from struct_sdk.core import _safe, _current_session_id, is_genai_suppressed
|
|
309
430
|
model = kwargs.get("model", "unknown")
|
|
310
431
|
|
|
311
432
|
# Suppression path: a framework layer (LangChain handler) already
|
|
312
433
|
# owns the chat span. Don't create a duplicate; pass through.
|
|
313
434
|
if is_genai_suppressed():
|
|
314
|
-
return
|
|
435
|
+
return original(*args, **kwargs)
|
|
315
436
|
|
|
316
437
|
span: Optional[trace.Span] = None
|
|
317
438
|
|
|
@@ -340,18 +461,19 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
340
461
|
|
|
341
462
|
_safe(set_pre_call_attrs, site="anthropic.stream.pre_call_attrs")
|
|
342
463
|
|
|
343
|
-
stream_manager =
|
|
464
|
+
stream_manager = original(*args, **kwargs)
|
|
344
465
|
|
|
345
|
-
if span is
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
stream_manager._struct_model = model # type: ignore[attr-defined]
|
|
350
|
-
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
|
|
351
470
|
|
|
352
|
-
|
|
471
|
+
def wrap_mgr() -> None:
|
|
472
|
+
nonlocal result
|
|
473
|
+
result = _TracedAsyncStreamManager(stream_manager, span, sdk, model, otel_logger)
|
|
353
474
|
|
|
354
|
-
|
|
475
|
+
_safe(wrap_mgr, site="anthropic.stream.wrap_async")
|
|
476
|
+
return result
|
|
355
477
|
else:
|
|
356
478
|
@functools.wraps(original)
|
|
357
479
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
@@ -392,16 +514,17 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
392
514
|
|
|
393
515
|
stream_manager = original(*args, **kwargs)
|
|
394
516
|
|
|
395
|
-
if span is
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
stream_manager._struct_model = model # type: ignore[attr-defined]
|
|
400
|
-
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
|
|
401
521
|
|
|
402
|
-
|
|
522
|
+
def wrap_mgr() -> None:
|
|
523
|
+
nonlocal result
|
|
524
|
+
result = _TracedStreamManager(stream_manager, span, sdk, model, otel_logger)
|
|
403
525
|
|
|
404
|
-
|
|
526
|
+
_safe(wrap_mgr, site="anthropic.stream.wrap_sync")
|
|
527
|
+
return result
|
|
405
528
|
|
|
406
529
|
wrapper.__struct_wrapped__ = True # type: ignore[attr-defined]
|
|
407
530
|
wrapper.__struct_original__ = original # type: ignore[attr-defined]
|
|
@@ -973,8 +1096,3 @@ def _safe_json(obj: Any) -> str:
|
|
|
973
1096
|
return _truncate_and_serialize(obj)
|
|
974
1097
|
except Exception:
|
|
975
1098
|
return "[]"
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
def _is_coroutine(fn: Any) -> bool:
|
|
979
|
-
import asyncio
|
|
980
|
-
return asyncio.iscoroutinefunction(fn)
|
|
@@ -14,7 +14,6 @@ import functools
|
|
|
14
14
|
import json
|
|
15
15
|
import logging
|
|
16
16
|
import threading
|
|
17
|
-
import uuid
|
|
18
17
|
from enum import Enum
|
|
19
18
|
from typing import Any, Callable, Optional
|
|
20
19
|
from importlib.metadata import version as _pkg_version
|
|
@@ -33,6 +32,12 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
|
33
32
|
from opentelemetry.trace import StatusCode
|
|
34
33
|
|
|
35
34
|
logger = logging.getLogger("struct_sdk")
|
|
35
|
+
# A library must not configure logging on the host's behalf. Attach a NullHandler
|
|
36
|
+
# to the top-level ``struct_sdk`` logger so that, absent host configuration, the
|
|
37
|
+
# SDK's own records are dropped instead of triggering the "No handlers could be
|
|
38
|
+
# found" fallback. Child loggers (struct_sdk.anthropic, .langchain, ...) propagate
|
|
39
|
+
# here, so one handler covers them all. (Python logging library guidance.)
|
|
40
|
+
logger.addHandler(logging.NullHandler())
|
|
36
41
|
|
|
37
42
|
try:
|
|
38
43
|
_SDK_VERSION = _pkg_version("struct-sdk")
|
|
@@ -96,27 +101,100 @@ def is_genai_suppressed() -> bool:
|
|
|
96
101
|
return bool(_otel_context.get_value(_GENAI_SUPPRESS_KEY))
|
|
97
102
|
|
|
98
103
|
|
|
99
|
-
def suppress_genai_token() -> Token[_OtelContext]:
|
|
104
|
+
def suppress_genai_token() -> "tuple[Token[_OtelContext], Optional[asyncio.Task[Any]]]":
|
|
100
105
|
"""Attach the suppression key to the current OTel context.
|
|
101
106
|
|
|
102
|
-
Returns
|
|
103
|
-
suppression window ends.
|
|
104
|
-
``
|
|
107
|
+
Returns ``(token, enter_task)`` that MUST be passed to ``reset_genai()`` when
|
|
108
|
+
the suppression window ends. The suppression window spans TWO LangChain
|
|
109
|
+
callbacks (``on_chat_model_start`` -> ``on_llm_end``) that LangGraph can
|
|
110
|
+
schedule on DIFFERENT asyncio tasks, so the enter-task travels with the token
|
|
111
|
+
for a task-affinity-safe detach.
|
|
105
112
|
"""
|
|
106
|
-
|
|
113
|
+
token = _otel_context.attach(_otel_context.set_value(_GENAI_SUPPRESS_KEY, True))
|
|
114
|
+
return token, _current_task_or_none()
|
|
107
115
|
|
|
108
116
|
|
|
109
|
-
def reset_genai(
|
|
110
|
-
"""Detach the suppression key
|
|
117
|
+
def reset_genai(pair: "Optional[tuple[Token[_OtelContext], Optional[asyncio.Task[Any]]]]") -> None:
|
|
118
|
+
"""Detach the suppression key — ONLY on the asyncio task it was attached on.
|
|
111
119
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
Detaching a token from a foreign task's ``contextvars.Context`` makes
|
|
121
|
+
``opentelemetry.context.detach`` raise AND log ``Failed to detach context`` at
|
|
122
|
+
ERROR *internally, before our try/except runs* — the same spam that bit Fermat
|
|
123
|
+
via the LangChain suppression path. Skipping the detach cross-task is correct:
|
|
124
|
+
the foreign context is being torn down anyway. Sync code (no loop) always
|
|
125
|
+
detaches — it can't hop tasks.
|
|
115
126
|
"""
|
|
127
|
+
if pair is None:
|
|
128
|
+
return
|
|
129
|
+
token, enter_task = pair
|
|
130
|
+
if token is None:
|
|
131
|
+
return
|
|
132
|
+
# KNOWN LIMITATION (deliberate — do NOT "fix" by forcing the detach below):
|
|
133
|
+
# on a cross-task reset we skip the detach, so the task that ATTACHED the key
|
|
134
|
+
# keeps ``struct.suppress_genai=True`` in its own context. contextvars are
|
|
135
|
+
# task-isolated, so a foreign task literally cannot reset the entering task's
|
|
136
|
+
# var — attempting it IS the cross-context detach that raises and re-spams
|
|
137
|
+
# "Failed to detach context" (the 0.2.8 production breakage). A surviving
|
|
138
|
+
# entering task that later makes a RAW (non-LangChain) provider call could be
|
|
139
|
+
# silently suppressed. Accepted: cross-task reset only happens on abnormal
|
|
140
|
+
# teardown (client disconnect) where that context is abandoned anyway, and the
|
|
141
|
+
# real SSE shape attaches on an ephemeral pump task that dies. A clean restore
|
|
142
|
+
# would require abandoning the context-key suppression model — tracked follow-up.
|
|
143
|
+
if _current_task_or_none() is enter_task:
|
|
144
|
+
try:
|
|
145
|
+
_otel_context.detach(token)
|
|
146
|
+
except Exception: # noqa: BLE001 — never fail the host
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _current_task_or_none() -> Optional["asyncio.Task[Any]"]:
|
|
116
151
|
try:
|
|
152
|
+
return asyncio.current_task()
|
|
153
|
+
except RuntimeError: # no running event loop (sync code)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _attach_current_span(
|
|
158
|
+
span: trace.Span,
|
|
159
|
+
) -> tuple[Token[_OtelContext], Optional["asyncio.Task[Any]"]]:
|
|
160
|
+
"""Make ``span`` the current OTel span so children — customer-created raw spans
|
|
161
|
+
AND nested SDK spans — nest under it (ambient-context honoring, good-citizen
|
|
162
|
+
behavior). Returns ``(token, enter_task)`` for a later task-safe detach."""
|
|
163
|
+
token = _otel_context.attach(trace.set_span_in_context(span))
|
|
164
|
+
return token, _current_task_or_none()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _detach_current_span(
|
|
168
|
+
token: Optional[Token[_OtelContext]], enter_task: Optional["asyncio.Task[Any]"]
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Detach the OTel context token ONLY when we're on the SAME asyncio task it was
|
|
171
|
+
attached on.
|
|
172
|
+
|
|
173
|
+
A different task has a different ``contextvars.Context``, so
|
|
174
|
+
``opentelemetry.context.detach`` would raise AND log ``Failed to detach context``
|
|
175
|
+
at ERROR *internally, before any guard of ours runs* — exactly the
|
|
176
|
+
streaming/SSE bug (a generator's ``aclose()`` driven from a foreign task on
|
|
177
|
+
client disconnect). Skipping the detach there is correct: the foreign context is
|
|
178
|
+
being torn down anyway and our token belongs to the original context, which is no
|
|
179
|
+
longer active here. Sync code (no running loop) always detaches — it can't hop
|
|
180
|
+
tasks. This is what makes ``struct.agent()``/``struct.tool()`` teardown
|
|
181
|
+
task-affinity-safe while still participating in the ambient OTel stack."""
|
|
182
|
+
if token is None:
|
|
183
|
+
return
|
|
184
|
+
# KNOWN LIMITATION (deliberate — do NOT "fix" by forcing the detach or dropping
|
|
185
|
+
# the ambient attach): a cross-task exit leaves the ENTERING task with this
|
|
186
|
+
# (now-ended) span as its ambient current span. contextvars are task-isolated,
|
|
187
|
+
# so a foreign task cannot reset the entering task's var without the
|
|
188
|
+
# cross-context detach error ("Failed to detach context" — the 0.2.8 breakage).
|
|
189
|
+
# If that task survives and creates more spans they parent under the ended span
|
|
190
|
+
# (a slightly-wrong parent, not corruption). The two "fixes" both regress:
|
|
191
|
+
# forcing the detach re-spams the ERROR; dropping the ambient attach orphans
|
|
192
|
+
# customer-created raw OTel spans out of the agent's trace (loses nesting).
|
|
193
|
+
# Cross-task exit only happens on abnormal teardown (disconnect) where the
|
|
194
|
+
# entering context is abandoned. Tracked as an architectural follow-up.
|
|
195
|
+
if _current_task_or_none() is enter_task:
|
|
117
196
|
_otel_context.detach(token)
|
|
118
|
-
|
|
119
|
-
pass
|
|
197
|
+
|
|
120
198
|
|
|
121
199
|
# Registry of patched integrations — prevents double-patching
|
|
122
200
|
_patched_integrations: set[str] = set()
|
|
@@ -126,15 +204,21 @@ _first_failure_logged: set[str] = set()
|
|
|
126
204
|
|
|
127
205
|
|
|
128
206
|
def _safe(fn: Callable[[], None], *, site: str) -> None:
|
|
129
|
-
"""Run fn(); swallow any exception
|
|
207
|
+
"""Run fn(); swallow any exception so an SDK fault never reaches the host call.
|
|
208
|
+
|
|
209
|
+
Self-faults are logged at DEBUG ONLY — a telemetry SDK must stay silent at
|
|
210
|
+
WARN+; the whole point of fault isolation is invisibility, and a WARN with a
|
|
211
|
+
traceback for an error we deliberately swallow is alarming noise. The first
|
|
212
|
+
failure per site carries the traceback; repeats are terse to avoid DEBUG spam.
|
|
213
|
+
"""
|
|
130
214
|
try:
|
|
131
215
|
fn()
|
|
132
216
|
except Exception:
|
|
133
217
|
if site in _first_failure_logged:
|
|
134
|
-
logger.debug("Struct SDK suppressed exception at %s", site
|
|
218
|
+
logger.debug("Struct SDK suppressed exception at %s", site)
|
|
135
219
|
else:
|
|
136
220
|
_first_failure_logged.add(site)
|
|
137
|
-
logger.
|
|
221
|
+
logger.debug("Struct SDK suppressed exception at %s", site, exc_info=True)
|
|
138
222
|
|
|
139
223
|
|
|
140
224
|
class StructSDK:
|
|
@@ -308,7 +392,7 @@ class StructSDK:
|
|
|
308
392
|
return self._tracer_provider.get_tracer(name, _SDK_VERSION)
|
|
309
393
|
|
|
310
394
|
def get_logger(self, name: str = "struct-sdk") -> Any:
|
|
311
|
-
"""Get an OTel logger from our isolated provider
|
|
395
|
+
"""Get an OTel logger from our isolated provider."""
|
|
312
396
|
if self._logger_provider is None:
|
|
313
397
|
raise RuntimeError("Call struct.init() before using the SDK")
|
|
314
398
|
return self._logger_provider.get_logger(name)
|
|
@@ -491,7 +575,8 @@ class _AgentContext:
|
|
|
491
575
|
self._version = version
|
|
492
576
|
self._metadata = metadata
|
|
493
577
|
self._span: Optional[trace.Span] = None
|
|
494
|
-
self.
|
|
578
|
+
self._otel_token: Optional[Token[_OtelContext]] = None
|
|
579
|
+
self._enter_task: Optional["asyncio.Task[Any]"] = None
|
|
495
580
|
self._session_token: Optional[contextvars.Token[Optional[str]]] = None
|
|
496
581
|
self._conversation_token: Optional[contextvars.Token[Optional[str]]] = None
|
|
497
582
|
self._agent_span_token: Optional[contextvars.Token[Optional[trace.Span]]] = None
|
|
@@ -551,8 +636,12 @@ class _AgentContext:
|
|
|
551
636
|
# No explicit id → inherit the enclosing agent's session (inline).
|
|
552
637
|
self._session_id = enclosing_session_id
|
|
553
638
|
else:
|
|
554
|
-
# No
|
|
555
|
-
|
|
639
|
+
# No explicit id AND no ambient session → leave the run
|
|
640
|
+
# SESSION-LESS (do NOT fabricate a uuid). The backend groups
|
|
641
|
+
# session-less spans by trace; a per-run uuid would render each as
|
|
642
|
+
# its own degenerate "session". (Spec: never fabricate
|
|
643
|
+
# gen_ai.conversation.id.)
|
|
644
|
+
self._session_id = ""
|
|
556
645
|
|
|
557
646
|
# ── Break-out detection ──────────────────────────────────────────
|
|
558
647
|
# Condition: caller supplied an EXPLICIT id AND it differs from the
|
|
@@ -631,8 +720,9 @@ class _AgentContext:
|
|
|
631
720
|
if self._version:
|
|
632
721
|
self._span.set_attribute("gen_ai.agent.version", self._version)
|
|
633
722
|
# gen_ai.conversation.id is the spec-blessed name; we drop the
|
|
634
|
-
# redundant session.id.
|
|
635
|
-
|
|
723
|
+
# redundant session.id. Omitted when session-less (never fabricated).
|
|
724
|
+
if self._session_id:
|
|
725
|
+
self._span.set_attribute("gen_ai.conversation.id", self._session_id)
|
|
636
726
|
# Link to the outer agent's session, if we're nested under one.
|
|
637
727
|
# For break-out agents: the parent is the enclosing agent.
|
|
638
728
|
# For inline nested agents: parent is the same session (same id).
|
|
@@ -658,16 +748,17 @@ class _AgentContext:
|
|
|
658
748
|
for key, value in self._metadata.items():
|
|
659
749
|
self._span.set_attribute(f"struct.metadata.{key}", value)
|
|
660
750
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
#
|
|
664
|
-
#
|
|
665
|
-
|
|
666
|
-
# nothing was pushed and __exit__ would corrupt the stack.
|
|
751
|
+
# Make this the current OTel span so children (incl. customer raw
|
|
752
|
+
# spans) nest under it. Detach is TASK-SAFE on exit (see
|
|
753
|
+
# _detach_current_span) — never the foreign-task detach that logged
|
|
754
|
+
# "Failed to detach context".
|
|
755
|
+
self._otel_token, self._enter_task = _attach_current_span(self._span)
|
|
667
756
|
entered = True
|
|
668
|
-
# Set context vars so child spans inherit session context
|
|
669
|
-
|
|
670
|
-
|
|
757
|
+
# Set context vars so child spans inherit session context. A
|
|
758
|
+
# session-less run propagates None (not ""), so descendants stay
|
|
759
|
+
# session-less rather than inheriting an empty string.
|
|
760
|
+
self._session_token = _current_session_id.set(self._session_id or None)
|
|
761
|
+
self._conversation_token = _current_conversation_id.set(self._session_id or None)
|
|
671
762
|
self._agent_span_token = _current_agent_span.set(self._span)
|
|
672
763
|
# Fresh pending-tool-calls dict scoped to this agent run, so tool_use
|
|
673
764
|
# ids from an outer agent cannot leak in or out.
|
|
@@ -710,13 +801,11 @@ class _AgentContext:
|
|
|
710
801
|
_safe(lambda: _current_session_id.reset(session_tok),
|
|
711
802
|
site="agent.start_span.reset_session")
|
|
712
803
|
self._session_token = None
|
|
713
|
-
#
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
site="agent.start_span.rollback_ctx_exit")
|
|
719
|
-
self._ctx_manager = None
|
|
804
|
+
# Detach the OTel context (task-safe) if we attached.
|
|
805
|
+
if entered and self._otel_token is not None:
|
|
806
|
+
_safe(lambda: _detach_current_span(self._otel_token, self._enter_task),
|
|
807
|
+
site="agent.start_span.rollback_detach")
|
|
808
|
+
self._otel_token = None
|
|
720
809
|
# Then end the span so it isn't leaked unended.
|
|
721
810
|
span = self._span
|
|
722
811
|
if span is not None:
|
|
@@ -759,9 +848,10 @@ class _AgentContext:
|
|
|
759
848
|
_safe(lambda: span.set_status(StatusCode.OK),
|
|
760
849
|
site="agent.end_span.set_ok")
|
|
761
850
|
_safe(span.end, site="agent.end_span.end")
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
851
|
+
if self._otel_token is not None:
|
|
852
|
+
_safe(lambda: _detach_current_span(self._otel_token, self._enter_task),
|
|
853
|
+
site="agent.end_span.detach")
|
|
854
|
+
self._otel_token = None
|
|
765
855
|
|
|
766
856
|
def __enter__(self) -> "_AgentContext":
|
|
767
857
|
self._start_span()
|
|
@@ -794,7 +884,8 @@ class _ToolContext:
|
|
|
794
884
|
self._name = name
|
|
795
885
|
self._tool_call_id = tool_call_id
|
|
796
886
|
self._span: Optional[trace.Span] = None
|
|
797
|
-
self.
|
|
887
|
+
self._otel_token: Optional[Token[_OtelContext]] = None
|
|
888
|
+
self._enter_task: Optional["asyncio.Task[Any]"] = None
|
|
798
889
|
self._result: Any = None
|
|
799
890
|
|
|
800
891
|
def __call__(self, fn: Any) -> Any:
|
|
@@ -862,23 +953,19 @@ class _ToolContext:
|
|
|
862
953
|
if session_id:
|
|
863
954
|
self._span.set_attribute("gen_ai.conversation.id", session_id)
|
|
864
955
|
|
|
865
|
-
|
|
866
|
-
self.
|
|
867
|
-
# Tracks whether the OTel context stack was actually pushed; only
|
|
868
|
-
# then is it correct to call __exit__ on rollback.
|
|
956
|
+
# Current OTel span for children; task-safe detach on exit.
|
|
957
|
+
self._otel_token, self._enter_task = _attach_current_span(self._span)
|
|
869
958
|
entered = True
|
|
870
959
|
started = True
|
|
871
960
|
|
|
872
961
|
_safe(body, site="tool.start_span")
|
|
873
962
|
if not started:
|
|
874
|
-
# Body raised partway.
|
|
875
|
-
#
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
site="tool.start_span.rollback_ctx_exit")
|
|
881
|
-
self._ctx_manager = None
|
|
963
|
+
# Body raised partway. Detach (task-safe) if attached, end the span if
|
|
964
|
+
# started, then drop references so _end_span sees a clean view.
|
|
965
|
+
if entered and self._otel_token is not None:
|
|
966
|
+
_safe(lambda: _detach_current_span(self._otel_token, self._enter_task),
|
|
967
|
+
site="tool.start_span.rollback_detach")
|
|
968
|
+
self._otel_token = None
|
|
882
969
|
span = self._span
|
|
883
970
|
if span is not None:
|
|
884
971
|
_safe(span.end, site="tool.start_span.rollback_span_end")
|
|
@@ -898,9 +985,10 @@ class _ToolContext:
|
|
|
898
985
|
_safe(lambda: span.set_status(StatusCode.OK),
|
|
899
986
|
site="tool.end_span.set_ok")
|
|
900
987
|
_safe(span.end, site="tool.end_span.end")
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
988
|
+
if self._otel_token is not None:
|
|
989
|
+
_safe(lambda: _detach_current_span(self._otel_token, self._enter_task),
|
|
990
|
+
site="tool.end_span.detach")
|
|
991
|
+
self._otel_token = None
|
|
904
992
|
|
|
905
993
|
def _set_arguments(self, kwargs: dict) -> None:
|
|
906
994
|
"""Set tool call arguments (opt-in)."""
|
|
@@ -393,7 +393,13 @@ def _is_agent_chain(
|
|
|
393
393
|
if isinstance(lg_node, str) and lg_node and lg_node == run_name:
|
|
394
394
|
return False
|
|
395
395
|
if run_name:
|
|
396
|
-
|
|
396
|
+
# LangChain names parametrized runnables ``Base<...>`` — e.g.
|
|
397
|
+
# ``RunnableParallel<raw>`` and ``RunnableAssign<parsed,parsing_error>``
|
|
398
|
+
# from ``with_structured_output(include_raw=True)``. Strip the ``<...>``
|
|
399
|
+
# so the base class matches the denylist instead of falling through to
|
|
400
|
+
# the user-named-chain promotion below (the Fermat phantom-agent bug).
|
|
401
|
+
base_name = run_name.split("<", 1)[0]
|
|
402
|
+
if base_name in _INTERNAL_RUN_NAMES:
|
|
397
403
|
return False
|
|
398
404
|
if any(run_name.startswith(p) for p in _INTERNAL_RUN_NAME_PREFIXES):
|
|
399
405
|
return False
|
|
@@ -401,6 +407,31 @@ def _is_agent_chain(
|
|
|
401
407
|
return False
|
|
402
408
|
|
|
403
409
|
|
|
410
|
+
def _forced_tool_name(invocation: Optional[dict[str, Any]]) -> Optional[str]:
|
|
411
|
+
"""Return the tool name a forced ``tool_choice`` pins, else None.
|
|
412
|
+
|
|
413
|
+
``with_structured_output`` forces the model to call a single schema-tool, so
|
|
414
|
+
a tool_call matching this name is structured output (a forced extraction
|
|
415
|
+
LangChain parses into the Pydantic object) — never executed, so it must not
|
|
416
|
+
be treated as an agentic tool call. Grounded in a real
|
|
417
|
+
``ChatAnthropic.with_structured_output`` probe:
|
|
418
|
+
Anthropic: ``tool_choice = {"type": "tool", "name": X}``
|
|
419
|
+
OpenAI: ``tool_choice = {"type": "function", "function": {"name": X}}``
|
|
420
|
+
"""
|
|
421
|
+
if not isinstance(invocation, dict):
|
|
422
|
+
return None
|
|
423
|
+
tc = invocation.get("tool_choice")
|
|
424
|
+
if not isinstance(tc, dict):
|
|
425
|
+
return None
|
|
426
|
+
if tc.get("type") == "tool" and isinstance(tc.get("name"), str):
|
|
427
|
+
return tc["name"]
|
|
428
|
+
if tc.get("type") == "function":
|
|
429
|
+
fn = tc.get("function")
|
|
430
|
+
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
|
|
431
|
+
return fn["name"]
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
404
435
|
# ---------------------------------------------------------------------------
|
|
405
436
|
# Message conversion helpers — LangChain message → OTel GenAI parts
|
|
406
437
|
# ---------------------------------------------------------------------------
|
|
@@ -718,7 +749,8 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
718
749
|
span.set_attribute("gen_ai.agent.name", str(agent_name))
|
|
719
750
|
# Don't set gen_ai.agent.id from session_id — the spec uses agent.id
|
|
720
751
|
# for a stable agent-definition identifier, not per-invocation.
|
|
721
|
-
|
|
752
|
+
if session_id:
|
|
753
|
+
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
722
754
|
# Always set parent_session_id when there's a parent agent — even
|
|
723
755
|
# if it matches our own session_id (which happens when the SDK's
|
|
724
756
|
# ambient ``_current_session_id`` propagates through nested
|
|
@@ -855,7 +887,8 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
855
887
|
span.set_attribute("gen_ai.operation.name", "chat")
|
|
856
888
|
span.set_attribute("gen_ai.provider.name", provider)
|
|
857
889
|
span.set_attribute("gen_ai.request.model", str(model))
|
|
858
|
-
|
|
890
|
+
if session_id:
|
|
891
|
+
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
859
892
|
|
|
860
893
|
_set_request_attrs(span, invocation)
|
|
861
894
|
|
|
@@ -895,6 +928,7 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
895
928
|
nearest_agent_span=self._inherited_agent_span(parent_key),
|
|
896
929
|
kind="llm",
|
|
897
930
|
enrich_token=enrich_token,
|
|
931
|
+
forced_tool_name=_forced_tool_name(invocation),
|
|
898
932
|
)
|
|
899
933
|
|
|
900
934
|
def on_llm_start(
|
|
@@ -970,9 +1004,23 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
970
1004
|
tool_calls = getattr(message, "tool_calls", None) or []
|
|
971
1005
|
if not tool_calls:
|
|
972
1006
|
return
|
|
1007
|
+
# A tool_call matching a forced tool_choice is structured output
|
|
1008
|
+
# (with_structured_output) — never executed, so it must NOT become a
|
|
1009
|
+
# pending tool-call (that would dangle in the waterfall as a tool with
|
|
1010
|
+
# no execution). Mark the chat output as structured (json) instead.
|
|
1011
|
+
forced = r.forced_tool_name
|
|
1012
|
+
real = []
|
|
1013
|
+
structured_seen = False
|
|
1014
|
+
for tc in tool_calls:
|
|
1015
|
+
if forced and tc.get("name") == forced:
|
|
1016
|
+
structured_seen = True
|
|
1017
|
+
continue
|
|
1018
|
+
real.append(tc)
|
|
1019
|
+
if structured_seen:
|
|
1020
|
+
span.set_attribute("gen_ai.output.type", "json")
|
|
973
1021
|
pairs = [
|
|
974
1022
|
(tc.get("name", ""), tc.get("id", ""))
|
|
975
|
-
for tc in
|
|
1023
|
+
for tc in real
|
|
976
1024
|
if tc.get("name") and tc.get("id")
|
|
977
1025
|
]
|
|
978
1026
|
if pairs:
|
|
@@ -1080,7 +1128,8 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
1080
1128
|
if tool_call_id:
|
|
1081
1129
|
span.set_attribute("gen_ai.tool.call.id", tool_call_id)
|
|
1082
1130
|
|
|
1083
|
-
|
|
1131
|
+
if session_id:
|
|
1132
|
+
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
1084
1133
|
|
|
1085
1134
|
if self._sdk.capture_content and input_str:
|
|
1086
1135
|
span.set_attribute(
|
|
@@ -1214,7 +1263,8 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
1214
1263
|
span.set_attribute("gen_ai.operation.name", "retrieval")
|
|
1215
1264
|
span.set_attribute("gen_ai.provider.name", "langchain")
|
|
1216
1265
|
span.set_attribute("gen_ai.data_source.id", str(name))
|
|
1217
|
-
|
|
1266
|
+
if session_id:
|
|
1267
|
+
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
1218
1268
|
if self._sdk.capture_content and query:
|
|
1219
1269
|
span.set_attribute("gen_ai.retrieval.query.text", str(query)[:4096])
|
|
1220
1270
|
|
|
@@ -1337,8 +1387,14 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
1337
1387
|
self,
|
|
1338
1388
|
parent_run_id: Optional[str],
|
|
1339
1389
|
metadata: Optional[dict[str, Any]],
|
|
1340
|
-
) -> str:
|
|
1341
|
-
"""For chat/tool/retriever spans — inherit from parent so everything rolls up.
|
|
1390
|
+
) -> Optional[str]:
|
|
1391
|
+
"""For chat/tool/retriever spans — inherit from parent so everything rolls up.
|
|
1392
|
+
|
|
1393
|
+
Returns None when there is no session signal (no parent, no thread_id, no
|
|
1394
|
+
ambient struct.agent) — the span is left session-less so the backend groups
|
|
1395
|
+
it by trace, rather than fabricating a per-run uuid that renders as its own
|
|
1396
|
+
degenerate session (Fermat prefetch-.ainvoke() orphan bug; spec: never
|
|
1397
|
+
fabricate gen_ai.conversation.id)."""
|
|
1342
1398
|
if parent_run_id:
|
|
1343
1399
|
p = self._runs.get(parent_run_id)
|
|
1344
1400
|
if p and p.session_id:
|
|
@@ -1350,13 +1406,13 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
1350
1406
|
ambient = _current_session_id.get(None)
|
|
1351
1407
|
if ambient:
|
|
1352
1408
|
return ambient
|
|
1353
|
-
return
|
|
1409
|
+
return None
|
|
1354
1410
|
|
|
1355
1411
|
def _resolve_agent_session_id(
|
|
1356
1412
|
self,
|
|
1357
1413
|
metadata: Optional[dict[str, Any]],
|
|
1358
1414
|
parent_agent_session_id: Optional[str] = None,
|
|
1359
|
-
) -> str:
|
|
1415
|
+
) -> Optional[str]:
|
|
1360
1416
|
"""Agent spans INHERIT the nearest-agent ancestor's conversation id so a
|
|
1361
1417
|
whole run shares one id. A locally-supplied thread_id is preserved by the
|
|
1362
1418
|
caller as ``struct.agent.thread_id`` (non-grouping) — it never splits the run.
|
|
@@ -1370,6 +1426,9 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
1370
1426
|
ambient = _current_session_id.get(None)
|
|
1371
1427
|
if ambient:
|
|
1372
1428
|
return ambient
|
|
1429
|
+
# A standalone agent run with no session signal is still a coherent
|
|
1430
|
+
# session; mint a per-run id so its sub-spans group. (Orphan leaf
|
|
1431
|
+
# tool/chat spans stay session-less — see _resolve_session_id.)
|
|
1373
1432
|
return str(uuid.uuid4())
|
|
1374
1433
|
|
|
1375
1434
|
|
|
@@ -1394,6 +1453,10 @@ class _RunState:
|
|
|
1394
1453
|
# provider-SDK instrumentations (anthropic, openai, etc.)
|
|
1395
1454
|
# "a framework layer owns the chat span — skip creating a duplicate."
|
|
1396
1455
|
"enrich_token",
|
|
1456
|
+
# On chat runs: the tool name a forced ``tool_choice`` pins (i.e.
|
|
1457
|
+
# ``with_structured_output``). A tool_call matching it is structured
|
|
1458
|
+
# output, not an agentic tool call. See _forced_tool_name.
|
|
1459
|
+
"forced_tool_name",
|
|
1397
1460
|
)
|
|
1398
1461
|
|
|
1399
1462
|
def __init__(
|
|
@@ -1401,11 +1464,12 @@ class _RunState:
|
|
|
1401
1464
|
*,
|
|
1402
1465
|
span: Optional[trace.Span],
|
|
1403
1466
|
effective_parent_span: Optional[trace.Span],
|
|
1404
|
-
session_id: str,
|
|
1467
|
+
session_id: Optional[str],
|
|
1405
1468
|
nearest_agent_session_id: Optional[str],
|
|
1406
1469
|
nearest_agent_span: Optional[trace.Span],
|
|
1407
1470
|
kind: str,
|
|
1408
1471
|
enrich_token: Any = None,
|
|
1472
|
+
forced_tool_name: Optional[str] = None,
|
|
1409
1473
|
) -> None:
|
|
1410
1474
|
self.span = span
|
|
1411
1475
|
self.effective_parent_span = effective_parent_span
|
|
@@ -1414,6 +1478,7 @@ class _RunState:
|
|
|
1414
1478
|
self.nearest_agent_span = nearest_agent_span
|
|
1415
1479
|
self.kind = kind
|
|
1416
1480
|
self.enrich_token = enrich_token
|
|
1481
|
+
self.forced_tool_name = forced_tool_name
|
|
1417
1482
|
|
|
1418
1483
|
|
|
1419
1484
|
class _ParentInfo:
|
|
@@ -1464,7 +1529,7 @@ def _set_llm_response_attrs(
|
|
|
1464
1529
|
otel_logger: Any,
|
|
1465
1530
|
message: Any,
|
|
1466
1531
|
provider: Optional[str],
|
|
1467
|
-
session_id: str,
|
|
1532
|
+
session_id: Optional[str],
|
|
1468
1533
|
) -> None:
|
|
1469
1534
|
usage = getattr(message, "usage_metadata", None) or {}
|
|
1470
1535
|
if isinstance(usage, dict):
|
|
@@ -1596,7 +1661,7 @@ def _emit_message_events(
|
|
|
1596
1661
|
otel_logger: Any,
|
|
1597
1662
|
messages: list[Any],
|
|
1598
1663
|
provider: str,
|
|
1599
|
-
session_id: str,
|
|
1664
|
+
session_id: Optional[str],
|
|
1600
1665
|
span: Optional[trace.Span] = None,
|
|
1601
1666
|
) -> None:
|
|
1602
1667
|
try:
|
|
@@ -1619,8 +1684,11 @@ def _emit_message_events(
|
|
|
1619
1684
|
"body": payload,
|
|
1620
1685
|
"gen_ai.provider.name": provider,
|
|
1621
1686
|
"gen_ai.message.index": idx,
|
|
1622
|
-
"gen_ai.conversation.id": session_id,
|
|
1623
1687
|
}
|
|
1688
|
+
# Omit when session-less — OTel attribute values must be non-null,
|
|
1689
|
+
# and we never fabricate a conversation.id for orphan runs.
|
|
1690
|
+
if session_id:
|
|
1691
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
1624
1692
|
otel_logger.emit(LogRecord(
|
|
1625
1693
|
timestamp=int(time.time_ns()),
|
|
1626
1694
|
trace_id=span_ctx.trace_id,
|
|
@@ -1638,7 +1706,7 @@ def _emit_choice_event(
|
|
|
1638
1706
|
otel_logger: Any,
|
|
1639
1707
|
message: Any,
|
|
1640
1708
|
provider: str,
|
|
1641
|
-
session_id: str,
|
|
1709
|
+
session_id: Optional[str],
|
|
1642
1710
|
span: Optional[trace.Span] = None,
|
|
1643
1711
|
) -> None:
|
|
1644
1712
|
try:
|
|
@@ -1660,8 +1728,11 @@ def _emit_choice_event(
|
|
|
1660
1728
|
"event.name": event_name,
|
|
1661
1729
|
"body": payload,
|
|
1662
1730
|
"gen_ai.provider.name": provider,
|
|
1663
|
-
"gen_ai.conversation.id": session_id,
|
|
1664
1731
|
}
|
|
1732
|
+
# Omit when session-less — OTel attribute values must be non-null, and we
|
|
1733
|
+
# never fabricate a conversation.id for orphan runs.
|
|
1734
|
+
if session_id:
|
|
1735
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
1665
1736
|
otel_logger.emit(LogRecord(
|
|
1666
1737
|
timestamp=int(time.time_ns()),
|
|
1667
1738
|
trace_id=span_ctx.trace_id,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|