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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: struct-sdk
3
- Version: 0.2.8
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>=0.3.0; extra == 'demo'
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.0; extra == 'demo'
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.8"
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.0",
43
+ "langchain>=1.3.9",
44
44
  "langchain-core>=1.3.3",
45
45
  "langchain-openai>=0.2.0",
46
- "langchain-anthropic>=0.3.0",
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
- KNOWN GAP: this wrapper creates the chat span and stashes it on the
297
- stream_manager for use by downstream code, but does NOT currently hook
298
- the stream's finalization to call _set_response_attrs on the
299
- accumulated final message. As a result, streaming chat calls do not
300
- populate the _pending_tool_calls contextvar, and ``@struct.tool()``
301
- callers downstream of a ``messages.stream(...)`` call must pass
302
- ``tool_call_id=`` explicitly until this is fixed. Non-streaming
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
- async def wrapper(*args: Any, **kwargs: Any) -> Any:
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 await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
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 = await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
464
+ stream_manager = original(*args, **kwargs)
344
465
 
345
- if span is not None:
346
- def stash_attrs() -> None:
347
- stream_manager._struct_span = span # type: ignore[attr-defined]
348
- stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
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
- _safe(stash_attrs, site="anthropic.stream.stash_attrs")
471
+ def wrap_mgr() -> None:
472
+ nonlocal result
473
+ result = _TracedAsyncStreamManager(stream_manager, span, sdk, model, otel_logger)
353
474
 
354
- return stream_manager
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 not None:
396
- def stash_attrs() -> None:
397
- stream_manager._struct_span = span # type: ignore[attr-defined]
398
- stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
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
- _safe(stash_attrs, site="anthropic.stream.stash_attrs")
522
+ def wrap_mgr() -> None:
523
+ nonlocal result
524
+ result = _TracedStreamManager(stream_manager, span, sdk, model, otel_logger)
403
525
 
404
- return stream_manager
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 an opaque token that MUST be passed to ``reset_genai()`` when the
103
- suppression window ends. Follows the same attach/detach contract as
104
- ``opentelemetry.context.attach`` / ``detach``.
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
- return _otel_context.attach(_otel_context.set_value(_GENAI_SUPPRESS_KEY, True))
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(token: Token[_OtelContext]) -> None:
110
- """Detach the suppression key token.
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
- Tolerant of cross-context detach (e.g. async tasks that detach from a
113
- different context than the one that attached): the exception is swallowed
114
- so instrumentation never fails the host call.
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
- except Exception: # noqa: BLE001 — cross-context detach is a no-op, never fail the host
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. First failure per site logs at WARN with stack; subsequent at DEBUG."""
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, exc_info=True)
218
+ logger.debug("Struct SDK suppressed exception at %s", site)
135
219
  else:
136
220
  _first_failure_logged.add(site)
137
- logger.warning("Struct SDK suppressed exception at %s", site, exc_info=True)
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 (for gen_ai log events)."""
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._ctx_manager: Optional[Any] = None
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 ambient context mint a fresh id for this root agent.
555
- self._session_id = str(uuid.uuid4())
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
- self._span.set_attribute("gen_ai.conversation.id", self._session_id)
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
- self._ctx_manager = trace.use_span(self._span, end_on_exit=False)
662
- self._ctx_manager.__enter__()
663
- # Tracks whether the OTel context stack was actually pushed; only
664
- # then is it correct to call __exit__ on rollback. If body raised
665
- # between assigning self._ctx_manager and __enter__ returning,
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
- self._session_token = _current_session_id.set(self._session_id)
670
- self._conversation_token = _current_conversation_id.set(self._session_id)
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
- # Pop the OTel context stack first use_span's __exit__ depends on
714
- # the span still being current. Only call it if __enter__ ran.
715
- ctx = self._ctx_manager
716
- if entered and ctx is not None:
717
- _safe(lambda: ctx.__exit__(None, None, None),
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
- ctx = self._ctx_manager
763
- if ctx is not None:
764
- _safe(lambda: ctx.__exit__(None, None, None), site="agent.end_span.ctx_exit")
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._ctx_manager: Optional[Any] = None
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
- self._ctx_manager = trace.use_span(self._span, end_on_exit=False)
866
- self._ctx_manager.__enter__()
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. Pop the OTel context stack if it was pushed,
875
- # end the span if it was started, then drop references so
876
- # _end_span sees a clean "no telemetry" view.
877
- ctx = self._ctx_manager
878
- if entered and ctx is not None:
879
- _safe(lambda: ctx.__exit__(None, None, None),
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
- ctx = self._ctx_manager
902
- if ctx is not None:
903
- _safe(lambda: ctx.__exit__(None, None, None), site="tool.end_span.ctx_exit")
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
- if run_name in _INTERNAL_RUN_NAMES:
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
- span.set_attribute("gen_ai.conversation.id", session_id)
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
- span.set_attribute("gen_ai.conversation.id", session_id)
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 tool_calls
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
- span.set_attribute("gen_ai.conversation.id", session_id)
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
- span.set_attribute("gen_ai.conversation.id", session_id)
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 str(uuid.uuid4())
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