struct-sdk 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1450 @@
1
+ """LangChain auto-instrumentation via the official BaseCallbackHandler API.
2
+
3
+ This is the same integration pattern used by LangSmith, Arize OpenInference,
4
+ Traceloop, and Langfuse. LangChain fires ``on_*_start`` / ``on_*_end``
5
+ callbacks for every chain / LLM / tool / retriever run, each carrying a
6
+ ``run_id`` and ``parent_run_id``. We build OTel spans from those events —
7
+ parentage comes from LangChain's own run tree rather than from Python's
8
+ contextvars, so it works correctly with every Runnable the framework knows
9
+ about (including LangGraph's Pregel, custom chains, and future Runnable
10
+ types).
11
+
12
+ Why we switched from monkey-patching:
13
+ - The previous implementation patched ``Pregel.invoke``, ``BaseTool.invoke``,
14
+ etc. That bets on LangChain's internal class surface, which churns across
15
+ releases, and misses custom Runnables entirely.
16
+ - Double-tracing with LangSmith was unavoidable: both approaches install
17
+ overlapping hooks with no de-dup story.
18
+ - The callback API is LangChain's public contract — stable across versions,
19
+ automatically covers every Runnable type, and is the pattern every other
20
+ tracing SDK in the ecosystem uses.
21
+
22
+ Auto-applied by ``struct.init()`` when ``langchain-core`` is installed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import time
30
+ import uuid
31
+ from typing import TYPE_CHECKING, Any, Optional
32
+ from uuid import UUID
33
+
34
+ from opentelemetry import trace
35
+ from opentelemetry.trace import StatusCode
36
+
37
+ if TYPE_CHECKING:
38
+ from struct_sdk.core import StructSDK
39
+
40
+ logger = logging.getLogger("struct_sdk.langchain")
41
+
42
+ _MAX_CONTENT_SIZE = 128 * 1024
43
+ _TRUNCATION_MARKER = "… [truncated]"
44
+ _MAX_FIELD_SIZE = 16384
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Module state — handler + configure-wrapper bookkeeping
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _STRUCT_WRAPPED = "_struct_wrapped_configure"
52
+ _active_handler: Optional["StructCallbackHandler"] = None
53
+ # (cls, original_classmethod) tuples so we can restore on unpatch.
54
+ _patched_configures: list[tuple[type, Any]] = []
55
+
56
+
57
+ def get_langchain_handler() -> Optional["StructCallbackHandler"]:
58
+ """The currently-registered callback handler, or None if not initialized.
59
+
60
+ Exported so callers can attach it explicitly via
61
+ ``runnable.invoke(x, config={"callbacks": [struct_sdk.get_langchain_handler()]})``
62
+ if global injection isn't in play for some reason.
63
+ """
64
+ return _active_handler
65
+
66
+
67
+ def patch(sdk: StructSDK) -> None:
68
+ """Install a StructCallbackHandler as a global LangChain callback.
69
+
70
+ Wraps ``CallbackManager.configure`` (and its async counterpart) so every
71
+ invoke/stream/batch call picks up our handler as an inheritable
72
+ callback. This is exactly how OpenInference and Traceloop's LangChain
73
+ instrumentations hook in.
74
+ """
75
+ global _active_handler
76
+
77
+ import langchain_core # type: ignore
78
+ from langchain_core.callbacks.manager import ( # type: ignore
79
+ AsyncCallbackManager,
80
+ CallbackManager,
81
+ )
82
+
83
+ if getattr(langchain_core, "__struct_patched", False):
84
+ _active_handler = _build_handler(sdk)
85
+ return
86
+
87
+ _active_handler = _build_handler(sdk)
88
+
89
+ for target in (CallbackManager, AsyncCallbackManager):
90
+ original_cm = target.__dict__.get("configure")
91
+ if original_cm is None:
92
+ continue
93
+ # Marker lives on the underlying function so the classmethod wrapper
94
+ # is transparent to the idempotency check.
95
+ original_func = getattr(original_cm, "__func__", original_cm)
96
+ if getattr(original_func, _STRUCT_WRAPPED, False):
97
+ continue
98
+
99
+ def _make_wrapper(orig_func: Any) -> Any:
100
+ def wrapped(
101
+ cls: Any,
102
+ inheritable_callbacks: Any = None,
103
+ local_callbacks: Any = None,
104
+ *args: Any,
105
+ **kwargs: Any,
106
+ ) -> Any:
107
+ inheritable_callbacks = _inject_handler(
108
+ inheritable_callbacks, _active_handler
109
+ )
110
+ return orig_func(
111
+ cls,
112
+ inheritable_callbacks,
113
+ local_callbacks,
114
+ *args,
115
+ **kwargs,
116
+ )
117
+
118
+ setattr(wrapped, _STRUCT_WRAPPED, True)
119
+ return classmethod(wrapped)
120
+
121
+ _patched_configures.append((target, original_cm))
122
+ setattr(target, "configure", _make_wrapper(original_func))
123
+
124
+ langchain_core.__struct_patched = True # type: ignore[attr-defined]
125
+
126
+
127
+ def unpatch() -> None:
128
+ """Restore the original CallbackManager.configure. Primarily for tests."""
129
+ global _active_handler
130
+
131
+ while _patched_configures:
132
+ cls, original_cm = _patched_configures.pop()
133
+ try:
134
+ setattr(cls, "configure", original_cm)
135
+ except Exception: # noqa: BLE001
136
+ pass
137
+
138
+ try:
139
+ import langchain_core # type: ignore
140
+ except ImportError:
141
+ _active_handler = None
142
+ return
143
+
144
+ _active_handler = None
145
+ langchain_core.__struct_patched = False # type: ignore[attr-defined]
146
+
147
+
148
+ def _build_handler(sdk: StructSDK) -> "StructCallbackHandler":
149
+ return StructCallbackHandler(
150
+ sdk,
151
+ sdk.get_tracer("struct-sdk-langchain"),
152
+ sdk.get_logger("struct-sdk-langchain") if sdk.emit_events else None,
153
+ )
154
+
155
+
156
+ def _inject_handler(existing: Any, handler: Optional["StructCallbackHandler"]) -> Any:
157
+ """Merge our handler into the inheritable_handlers argument, with de-dup."""
158
+ if handler is None:
159
+ return existing
160
+ if existing is None:
161
+ return [handler]
162
+ if isinstance(existing, list):
163
+ if any(getattr(h, "name", None) == "struct" for h in existing):
164
+ return existing
165
+ return [*existing, handler]
166
+ add = getattr(existing, "add_handler", None)
167
+ handlers = getattr(existing, "handlers", []) or []
168
+ if callable(add) and not any(
169
+ getattr(h, "name", None) == "struct" for h in handlers
170
+ ):
171
+ try:
172
+ add(handler, True)
173
+ except Exception: # noqa: BLE001
174
+ pass
175
+ return existing
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Provider / model detection
180
+ # ---------------------------------------------------------------------------
181
+
182
+ _PROVIDER_MAP: dict[str, str] = {
183
+ "ChatOpenAI": "openai",
184
+ "AzureChatOpenAI": "azure.openai",
185
+ "ChatAnthropic": "anthropic",
186
+ "ChatGoogleGenerativeAI": "gcp.generative_ai",
187
+ "ChatVertexAI": "gcp.vertex_ai",
188
+ "ChatCohere": "cohere",
189
+ "ChatMistralAI": "mistral",
190
+ "ChatGroq": "groq",
191
+ "ChatBedrock": "aws.bedrock",
192
+ "ChatBedrockConverse": "aws.bedrock",
193
+ "BedrockChat": "aws.bedrock",
194
+ "ChatFireworks": "fireworks",
195
+ "ChatTogether": "together",
196
+ "ChatOllama": "ollama",
197
+ "ChatDeepSeek": "deepseek",
198
+ }
199
+
200
+ _MODULE_PROVIDER_MAP: dict[str, str] = {
201
+ "openai": "openai",
202
+ "anthropic": "anthropic",
203
+ "google": "gcp.generative_ai",
204
+ "cohere": "cohere",
205
+ "mistral": "mistral",
206
+ "groq": "groq",
207
+ "bedrock": "aws.bedrock",
208
+ "fireworks": "fireworks",
209
+ "together": "together",
210
+ "ollama": "ollama",
211
+ "deepseek": "deepseek",
212
+ }
213
+
214
+
215
+ def _detect_provider_from_serialized(serialized: Optional[dict[str, Any]]) -> str:
216
+ if not isinstance(serialized, dict):
217
+ return "langchain"
218
+ cls_name = _extract_class_name(serialized)
219
+ if cls_name in _PROVIDER_MAP:
220
+ return _PROVIDER_MAP[cls_name]
221
+ ids = serialized.get("id")
222
+ module_path = ids[0] if isinstance(ids, list) and ids else ""
223
+ for key, provider in _MODULE_PROVIDER_MAP.items():
224
+ if key in module_path:
225
+ return provider
226
+ return "langchain"
227
+
228
+
229
+ def _extract_class_name(serialized: Optional[dict[str, Any]]) -> str:
230
+ """Last segment of ``serialized['id']`` is the class name. Handles None."""
231
+ if not isinstance(serialized, dict):
232
+ return ""
233
+ ids = serialized.get("id")
234
+ if isinstance(ids, list) and ids:
235
+ last = ids[-1]
236
+ if isinstance(last, str):
237
+ return last
238
+ name = serialized.get("name")
239
+ return name if isinstance(name, str) else ""
240
+
241
+
242
+ _AGENT_CLASSES = {
243
+ "AgentExecutor",
244
+ "CompiledStateGraph",
245
+ "CompiledGraph",
246
+ "Pregel",
247
+ "LangGraph",
248
+ }
249
+
250
+ # LangChain/LangGraph fires chain-start for every internal Runnable. In Python,
251
+ # ``serialized`` is usually ``None`` for these, so we filter on run_name via a
252
+ # denylist. Matches LangSmith's promotion heuristic.
253
+ _INTERNAL_RUN_NAMES = {
254
+ "RunnableSequence",
255
+ "RunnableLambda",
256
+ "RunnablePassthrough",
257
+ "RunnableParallel",
258
+ "RunnableBinding",
259
+ "RunnableMap",
260
+ "RunnableAssign",
261
+ "RunnableBranch",
262
+ "RunnableWithFallbacks",
263
+ "RunnableEach",
264
+ "RunnablePick",
265
+ "RunnableGenerator",
266
+ "Prompt",
267
+ "ChatPromptTemplate",
268
+ "PromptTemplate",
269
+ "agent",
270
+ "tools",
271
+ "call_model",
272
+ "should_continue",
273
+ "__start__",
274
+ "__end__",
275
+ }
276
+
277
+ _INTERNAL_RUN_NAME_PREFIXES = (
278
+ "ChannelWrite<",
279
+ "Branch<",
280
+ "RunnableSequence<",
281
+ )
282
+
283
+
284
+ def _is_agent_chain(
285
+ serialized: Optional[dict[str, Any]],
286
+ run_type: Optional[str],
287
+ run_name: Optional[str],
288
+ ) -> bool:
289
+ """Only promote user-meaningful chains to ``invoke_agent`` spans."""
290
+ if run_type == "agent":
291
+ return True
292
+ cls = _extract_class_name(serialized)
293
+ if cls in _AGENT_CLASSES:
294
+ return True
295
+ if run_name:
296
+ if run_name in _INTERNAL_RUN_NAMES:
297
+ return False
298
+ if any(run_name.startswith(p) for p in _INTERNAL_RUN_NAME_PREFIXES):
299
+ return False
300
+ return True
301
+ return False
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Message conversion helpers — LangChain message → OTel GenAI parts
306
+ # ---------------------------------------------------------------------------
307
+
308
+ _LANGCHAIN_FINISH_REASON_MAP = {
309
+ "end_turn": "stop",
310
+ "stop_sequence": "stop",
311
+ "max_tokens": "length",
312
+ "tool_use": "tool_calls",
313
+ "tool_calls": "tool_calls",
314
+ "function_call": "tool_calls",
315
+ }
316
+
317
+
318
+ def _message_to_role_and_parts(msg: Any) -> tuple[str, list[dict[str, Any]]]:
319
+ if isinstance(msg, dict):
320
+ role = msg.get("role", "user")
321
+ parts: list[dict[str, Any]] = []
322
+ content = msg.get("content")
323
+ if content:
324
+ parts.append({"type": "text", "content": str(content)})
325
+ return role, parts
326
+
327
+ cls_name = type(msg).__name__
328
+ parts = []
329
+
330
+ if cls_name == "ToolMessage":
331
+ tool_call_id = getattr(msg, "tool_call_id", "") or ""
332
+ content = getattr(msg, "content", "") or ""
333
+ parts.append({
334
+ "type": "tool_call_response",
335
+ "id": tool_call_id,
336
+ "response": content if isinstance(content, str) else str(content),
337
+ })
338
+ return "tool", parts
339
+
340
+ if cls_name == "SystemMessage":
341
+ role = "system"
342
+ elif cls_name == "HumanMessage":
343
+ role = "user"
344
+ elif cls_name in ("AIMessage", "AIMessageChunk"):
345
+ role = "assistant"
346
+ else:
347
+ role = getattr(msg, "role", None) or "user"
348
+
349
+ content = getattr(msg, "content", None)
350
+ if isinstance(content, str) and content:
351
+ parts.append({"type": "text", "content": content})
352
+ elif isinstance(content, list):
353
+ for block in content:
354
+ if isinstance(block, str):
355
+ parts.append({"type": "text", "content": block})
356
+ elif isinstance(block, dict):
357
+ bt = block.get("type")
358
+ if bt == "text":
359
+ parts.append({"type": "text", "content": block.get("text", "")})
360
+ elif bt == "image_url":
361
+ url = block.get("image_url", {})
362
+ if isinstance(url, dict):
363
+ u = url.get("url")
364
+ if isinstance(u, str):
365
+ parts.append({"type": "uri", "modality": "image", "uri": u})
366
+ elif isinstance(url, str):
367
+ parts.append({"type": "uri", "modality": "image", "uri": url})
368
+
369
+ if role == "assistant":
370
+ tool_calls = getattr(msg, "tool_calls", None) or []
371
+ for tc in tool_calls:
372
+ part: dict[str, Any] = {"type": "tool_call", "name": tc.get("name", "")}
373
+ if tc.get("id"):
374
+ part["id"] = tc["id"]
375
+ if tc.get("args") is not None:
376
+ part["arguments"] = tc["args"]
377
+ parts.append(part)
378
+
379
+ return role, parts
380
+
381
+
382
+ def _truncate_field(value: Any, max_len: int = _MAX_FIELD_SIZE) -> Any:
383
+ if isinstance(value, str) and len(value) > max_len:
384
+ return value[:max_len] + _TRUNCATION_MARKER
385
+ return value
386
+
387
+
388
+ def _truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
389
+ out = []
390
+ for part in parts:
391
+ part = dict(part)
392
+ if "content" in part and isinstance(part["content"], str):
393
+ part["content"] = _truncate_field(part["content"])
394
+ for key in ("arguments", "response"):
395
+ if key in part:
396
+ val = part[key]
397
+ if isinstance(val, str):
398
+ part[key] = _truncate_field(val)
399
+ elif isinstance(val, (dict, list)):
400
+ ser = json.dumps(val, default=str)
401
+ if len(ser) > _MAX_FIELD_SIZE:
402
+ part[key] = ser[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
403
+ out.append(part)
404
+ return out
405
+
406
+
407
+ def _truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
408
+ if isinstance(obj, list):
409
+ truncated: list[Any] = []
410
+ for item in obj:
411
+ if isinstance(item, dict):
412
+ item = dict(item)
413
+ if isinstance(item.get("parts"), list):
414
+ item["parts"] = _truncate_parts(item["parts"])
415
+ elif isinstance(item.get("content"), str):
416
+ item["content"] = _truncate_field(item["content"])
417
+ truncated.append(item)
418
+ result = json.dumps(truncated, default=str)
419
+ else:
420
+ result = json.dumps(obj, default=str)
421
+
422
+ if len(result) > max_size:
423
+ cut = result[: max_size - 50]
424
+ last_brace = cut.rfind("}")
425
+ result = cut[: last_brace + 1] + "]" if last_brace > 0 else "[]"
426
+ return result
427
+
428
+
429
+ def _last_user_parts(messages: list[Any]) -> Optional[list[dict[str, Any]]]:
430
+ for msg in reversed(messages):
431
+ role, parts = _message_to_role_and_parts(msg)
432
+ if role == "user":
433
+ return parts
434
+ return None
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # StructCallbackHandler
439
+ # ---------------------------------------------------------------------------
440
+
441
+ try:
442
+ from langchain_core.callbacks.base import BaseCallbackHandler # type: ignore
443
+ except ImportError: # pragma: no cover
444
+ class BaseCallbackHandler: # type: ignore[no-redef]
445
+ """Placeholder so struct_sdk imports cleanly when langchain-core is absent."""
446
+
447
+
448
+ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
449
+ """LangChain callback handler that emits OTel spans + log events.
450
+
451
+ Parent-child span linkage is built from LangChain's ``run_id`` /
452
+ ``parent_run_id`` — NOT from OTel's active context. That makes us robust
453
+ to LangGraph's internal task scheduling (which drops the active context
454
+ across microtasks) and matches LangSmith/OpenInference/Traceloop/Langfuse.
455
+
456
+ thread_id vs gen_ai.conversation.id
457
+ -----------------------------------
458
+
459
+ * ``config.configurable.thread_id`` (LangGraph) — checkpoint identifier.
460
+ Keys a conversation's state in the checkpointer (MemorySaver,
461
+ PostgresSaver). Multiple turns of the SAME conversation reuse one
462
+ thread_id so history accumulates.
463
+ * ``gen_ai.conversation.id`` (OTel GenAI spec) — the spec-blessed name
464
+ for the conversation/thread identifier. We emit this on every span as
465
+ the canonical session identifier.
466
+
467
+ We used to also emit ``session.id`` (generic OTel); that's been dropped
468
+ in favour of the GenAI-spec name.
469
+
470
+ For SUBAGENTS (an agent invoked from inside another's tool body) we
471
+ deliberately assign a DIFFERENT ``conversation.id`` — either the
472
+ subagent's own ``thread_id`` if supplied, or a fresh UUID. The resulting
473
+ subagent span is linked to the outer agent via our
474
+ ``struct.agent.parent_session_id`` attribute (what powers "Spawned by"
475
+ navigation in the UI). Without this split, subagent spans would collapse
476
+ into the outer session and hide delegation.
477
+
478
+ LangChain quirk (handled automatically): when ``agent.invoke(...)`` runs
479
+ nested inside a parent call, LangChain's config-merge inherits the
480
+ parent's ``metadata.thread_id`` onto the child — even if the child
481
+ config supplied its own. We detect that by comparing against the
482
+ nearest agent ancestor's session; if they match, treat as "inherited,
483
+ not user-intended" and assign a fresh UUID.
484
+
485
+ End-user guidance:
486
+ * Use thread_id per conversation; multi-turn chats reuse it.
487
+ * For a subagent call, pass a DIFFERENT thread_id (or omit it and let
488
+ LangGraph generate one). Subagents then surface as their own
489
+ sessions in the UI, linked back via parent_session_id.
490
+ """
491
+
492
+ name = "struct"
493
+ ignore_llm = False
494
+ ignore_chain = False
495
+ ignore_agent = False
496
+ ignore_retriever = False
497
+ ignore_chat_model = False
498
+ ignore_custom_event = True
499
+ raise_error = False
500
+ run_inline = True
501
+
502
+ def __init__(self, sdk: StructSDK, tracer: trace.Tracer, otel_logger: Any) -> None:
503
+ super().__init__()
504
+ self._sdk = sdk
505
+ self._tracer = tracer
506
+ self._logger = otel_logger
507
+ self._runs: dict[str, _RunState] = {}
508
+
509
+ # ── Chain / Agent ───────────────────────────────────────────────────────
510
+
511
+ def on_chain_start(
512
+ self,
513
+ serialized: dict[str, Any],
514
+ inputs: dict[str, Any],
515
+ *,
516
+ run_id: UUID,
517
+ parent_run_id: Optional[UUID] = None,
518
+ tags: Optional[list[str]] = None,
519
+ metadata: Optional[dict[str, Any]] = None,
520
+ **kwargs: Any,
521
+ ) -> None:
522
+ from struct_sdk.core import _safe
523
+
524
+ run_type = kwargs.get("run_type")
525
+ run_name = kwargs.get("name")
526
+ key = str(run_id)
527
+ parent_key = str(parent_run_id) if parent_run_id else None
528
+
529
+ if not _is_agent_chain(serialized or {}, run_type, run_name):
530
+ # Skipped chain — record entry so descendants can walk the parent
531
+ # chain and find the nearest agent ancestor's session id.
532
+ effective_parent = self._resolve_parent(parent_key).span
533
+ session_id = self._resolve_session_id(parent_key, metadata)
534
+ self._runs[key] = _RunState(
535
+ span=None,
536
+ effective_parent_span=effective_parent,
537
+ session_id=session_id,
538
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
539
+ nearest_agent_span=self._inherited_agent_span(parent_key),
540
+ kind="skipped-chain",
541
+ )
542
+ return
543
+
544
+ # For the parent_session_id linkage we need the NEAREST AGENT ancestor,
545
+ # not just the immediate parent (which might be a tool / llm / skipped
546
+ # chain). Use the cached nearest_agent_session_id field.
547
+ parent_agent_session_id = self._inherited_agent_session_id(parent_key)
548
+ session_id = self._resolve_agent_session_id(metadata, parent_agent_session_id)
549
+ parent = self._resolve_parent(parent_key)
550
+
551
+ agent_name: str = "agent"
552
+ span: Optional[trace.Span] = None
553
+
554
+ def create_span() -> None:
555
+ nonlocal span, agent_name
556
+ agent_name = (
557
+ run_name
558
+ or _extract_class_name(serialized or {})
559
+ or (inputs.get("name") if isinstance(inputs, dict) else None)
560
+ or "agent"
561
+ )
562
+ parent_ctx = trace.set_span_in_context(parent.span) if parent.span else None
563
+ span = self._tracer.start_span(
564
+ f"invoke_agent {agent_name}",
565
+ kind=trace.SpanKind.INTERNAL,
566
+ context=parent_ctx,
567
+ )
568
+
569
+ _safe(create_span, site="langchain.on_chain_start.create_span")
570
+
571
+ if span is None:
572
+ # No telemetry; record run_state without a span so end/error callbacks
573
+ # can find it and cleanly skip span operations. Descendants still
574
+ # get the inherited nearest-agent ancestry from the parent.
575
+ self._runs[key] = _RunState(
576
+ span=None,
577
+ effective_parent_span=parent.span,
578
+ session_id=session_id,
579
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
580
+ nearest_agent_span=self._inherited_agent_span(parent_key),
581
+ kind="chain",
582
+ )
583
+ return
584
+
585
+ def set_attrs() -> None:
586
+ assert span is not None
587
+ span.set_attribute("gen_ai.operation.name", "invoke_agent")
588
+ span.set_attribute("gen_ai.provider.name", "langchain")
589
+ span.set_attribute("gen_ai.agent.name", str(agent_name))
590
+ # Don't set gen_ai.agent.id from session_id — the spec uses agent.id
591
+ # for a stable agent-definition identifier, not per-invocation.
592
+ span.set_attribute("gen_ai.conversation.id", session_id)
593
+ if (
594
+ parent_agent_session_id
595
+ and parent_agent_session_id != session_id
596
+ ):
597
+ span.set_attribute("struct.agent.parent_session_id", parent_agent_session_id)
598
+
599
+ _safe(set_attrs, site="langchain.on_chain_start.start_attrs")
600
+
601
+ self._runs[key] = _RunState(
602
+ span=span,
603
+ effective_parent_span=span,
604
+ session_id=session_id,
605
+ nearest_agent_session_id=session_id, # self is the nearest agent for descendants
606
+ nearest_agent_span=span, # same — this span IS the agent
607
+ kind="chain",
608
+ )
609
+
610
+ def on_chain_end(
611
+ self,
612
+ outputs: Any,
613
+ *,
614
+ run_id: UUID,
615
+ parent_run_id: Optional[UUID] = None,
616
+ **kwargs: Any,
617
+ ) -> None:
618
+ from struct_sdk.core import _safe
619
+
620
+ r = self._runs.pop(str(run_id), None)
621
+ if not r or not r.span:
622
+ return
623
+ span = r.span
624
+ _safe(lambda: span.set_status(StatusCode.OK),
625
+ site="langchain.on_chain_end.set_status")
626
+ _safe(span.end, site="langchain.on_chain_end.span_end")
627
+
628
+ def on_chain_error(
629
+ self,
630
+ error: BaseException,
631
+ *,
632
+ run_id: UUID,
633
+ parent_run_id: Optional[UUID] = None,
634
+ **kwargs: Any,
635
+ ) -> None:
636
+ from struct_sdk.core import _safe
637
+
638
+ r = self._runs.pop(str(run_id), None)
639
+ if not r or not r.span:
640
+ return
641
+ span = r.span
642
+ _safe(lambda: _record_error(span, error),
643
+ site="langchain.on_chain_error.record_error")
644
+ _safe(span.end, site="langchain.on_chain_error.span_end")
645
+
646
+ # ── LLM / Chat model ────────────────────────────────────────────────────
647
+
648
+ def on_chat_model_start(
649
+ self,
650
+ serialized: dict[str, Any],
651
+ messages: list[list[Any]],
652
+ *,
653
+ run_id: UUID,
654
+ parent_run_id: Optional[UUID] = None,
655
+ tags: Optional[list[str]] = None,
656
+ metadata: Optional[dict[str, Any]] = None,
657
+ **kwargs: Any,
658
+ ) -> None:
659
+ from struct_sdk.core import _safe
660
+
661
+ key = str(run_id)
662
+ parent_key = str(parent_run_id) if parent_run_id else None
663
+ provider = _detect_provider_from_serialized(serialized or {})
664
+
665
+ invocation = kwargs.get("invocation_params") or {}
666
+ model = (
667
+ invocation.get("model")
668
+ or invocation.get("model_name")
669
+ or kwargs.get("name")
670
+ or "unknown"
671
+ )
672
+
673
+ parent = self._resolve_parent(parent_key)
674
+ session_id = self._resolve_session_id(parent_key, metadata)
675
+
676
+ span: Optional[trace.Span] = None
677
+
678
+ def create_span() -> None:
679
+ nonlocal span
680
+ parent_ctx = trace.set_span_in_context(parent.span) if parent.span else None
681
+ span = self._tracer.start_span(
682
+ f"chat {model}",
683
+ kind=trace.SpanKind.CLIENT,
684
+ context=parent_ctx,
685
+ )
686
+
687
+ _safe(create_span, site="langchain.on_chat_model_start.create_span")
688
+
689
+ if span is None:
690
+ self._runs[key] = _RunState(
691
+ span=None,
692
+ effective_parent_span=parent.span,
693
+ session_id=session_id,
694
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
695
+ nearest_agent_span=self._inherited_agent_span(parent_key),
696
+ kind="llm",
697
+ )
698
+ return
699
+
700
+ def set_attrs() -> None:
701
+ assert span is not None
702
+ span.set_attribute("gen_ai.operation.name", "chat")
703
+ span.set_attribute("gen_ai.provider.name", provider)
704
+ span.set_attribute("gen_ai.request.model", str(model))
705
+ span.set_attribute("gen_ai.conversation.id", session_id)
706
+
707
+ _set_request_attrs(span, invocation)
708
+
709
+ flat = [m for seq in messages for m in seq]
710
+ if flat:
711
+ span.set_attribute("struct.input.message_count", len(flat))
712
+ if self._sdk.emit_events and self._logger:
713
+ _emit_message_events(self._logger, flat, provider, session_id, span)
714
+ if self._sdk.emit_span_content:
715
+ span.set_attribute(
716
+ "gen_ai.input.messages",
717
+ _to_input_messages(flat),
718
+ )
719
+ ancestor_agent = parent.span if parent.is_agent else self._find_agent_ancestor(parent_key)
720
+ if ancestor_agent is not None:
721
+ _propagate_user_prompt(ancestor_agent, flat)
722
+
723
+ _safe(set_attrs, site="langchain.on_chat_model_start.start_attrs")
724
+
725
+ self._runs[key] = _RunState(
726
+ span=span,
727
+ effective_parent_span=span,
728
+ session_id=session_id,
729
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
730
+ nearest_agent_span=self._inherited_agent_span(parent_key),
731
+ kind="llm",
732
+ )
733
+
734
+ def on_llm_start(
735
+ self,
736
+ serialized: dict[str, Any],
737
+ prompts: list[str],
738
+ *,
739
+ run_id: UUID,
740
+ parent_run_id: Optional[UUID] = None,
741
+ tags: Optional[list[str]] = None,
742
+ metadata: Optional[dict[str, Any]] = None,
743
+ **kwargs: Any,
744
+ ) -> None:
745
+ messages = [[{"role": "user", "content": p}] for p in prompts]
746
+ self.on_chat_model_start(
747
+ serialized,
748
+ messages,
749
+ run_id=run_id,
750
+ parent_run_id=parent_run_id,
751
+ tags=tags,
752
+ metadata=metadata,
753
+ **kwargs,
754
+ )
755
+
756
+ def on_llm_end(
757
+ self,
758
+ response: Any,
759
+ *,
760
+ run_id: UUID,
761
+ parent_run_id: Optional[UUID] = None,
762
+ **kwargs: Any,
763
+ ) -> None:
764
+ from struct_sdk.core import _safe
765
+
766
+ r = self._runs.pop(str(run_id), None)
767
+ if not r or not r.span:
768
+ return
769
+ span = r.span
770
+
771
+ def set_response_attrs() -> None:
772
+ generations = getattr(response, "generations", None) or []
773
+ first = generations[0][0] if generations and generations[0] else None
774
+ message = getattr(first, "message", None) if first is not None else None
775
+ if message is None:
776
+ return
777
+ provider = span.attributes.get("gen_ai.provider.name") if hasattr(span, "attributes") else None # type: ignore[union-attr]
778
+ _set_llm_response_attrs(
779
+ span, self._sdk, self._logger, message, provider, r.session_id
780
+ )
781
+
782
+ def push_pending_tool_calls() -> None:
783
+ generations = getattr(response, "generations", None) or []
784
+ first = generations[0][0] if generations and generations[0] else None
785
+ message = getattr(first, "message", None) if first is not None else None
786
+ if message is None:
787
+ return
788
+ tool_calls = getattr(message, "tool_calls", None) or []
789
+ if not tool_calls:
790
+ return
791
+ pairs = [
792
+ (tc.get("name", ""), tc.get("id", ""))
793
+ for tc in tool_calls
794
+ if tc.get("name") and tc.get("id")
795
+ ]
796
+ if pairs:
797
+ _push_pending_tool_calls(pairs)
798
+
799
+ _safe(set_response_attrs, site="langchain.on_llm_end.set_response_attrs")
800
+ _safe(push_pending_tool_calls,
801
+ site="langchain.on_llm_end.record_pending_tool_calls")
802
+ _safe(lambda: span.set_status(StatusCode.OK),
803
+ site="langchain.on_llm_end.set_status")
804
+ _safe(span.end, site="langchain.on_llm_end.span_end")
805
+
806
+ def on_llm_error(
807
+ self,
808
+ error: BaseException,
809
+ *,
810
+ run_id: UUID,
811
+ parent_run_id: Optional[UUID] = None,
812
+ **kwargs: Any,
813
+ ) -> None:
814
+ from struct_sdk.core import _safe
815
+
816
+ r = self._runs.pop(str(run_id), None)
817
+ if not r or not r.span:
818
+ return
819
+ span = r.span
820
+ _safe(lambda: _record_error(span, error),
821
+ site="langchain.on_llm_error.record_error")
822
+ _safe(span.end, site="langchain.on_llm_error.span_end")
823
+
824
+ # ── Tool ────────────────────────────────────────────────────────────────
825
+
826
+ def on_tool_start(
827
+ self,
828
+ serialized: dict[str, Any],
829
+ input_str: str,
830
+ *,
831
+ run_id: UUID,
832
+ parent_run_id: Optional[UUID] = None,
833
+ tags: Optional[list[str]] = None,
834
+ metadata: Optional[dict[str, Any]] = None,
835
+ inputs: Optional[dict[str, Any]] = None,
836
+ **kwargs: Any,
837
+ ) -> None:
838
+ from struct_sdk.core import _safe
839
+
840
+ key = str(run_id)
841
+ parent_key = str(parent_run_id) if parent_run_id else None
842
+
843
+ tool_name = (
844
+ kwargs.get("name")
845
+ or (serialized.get("name") if isinstance(serialized, dict) else None)
846
+ or _extract_class_name(serialized or {})
847
+ or "tool"
848
+ )
849
+
850
+ parent = self._resolve_parent(parent_key)
851
+ session_id = self._resolve_session_id(parent_key, metadata)
852
+
853
+ span: Optional[trace.Span] = None
854
+
855
+ def create_span() -> None:
856
+ nonlocal span
857
+ parent_ctx = trace.set_span_in_context(parent.span) if parent.span else None
858
+ span = self._tracer.start_span(
859
+ f"execute_tool {tool_name}",
860
+ kind=trace.SpanKind.INTERNAL,
861
+ context=parent_ctx,
862
+ )
863
+
864
+ _safe(create_span, site="langchain.on_tool_start.create_span")
865
+
866
+ if span is None:
867
+ self._runs[key] = _RunState(
868
+ span=None,
869
+ effective_parent_span=parent.span,
870
+ session_id=session_id,
871
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
872
+ nearest_agent_span=self._inherited_agent_span(parent_key),
873
+ kind="tool",
874
+ )
875
+ return
876
+
877
+ def set_attrs() -> None:
878
+ assert span is not None
879
+ span.set_attribute("gen_ai.operation.name", "execute_tool")
880
+ span.set_attribute("gen_ai.provider.name", "langchain")
881
+ span.set_attribute("gen_ai.tool.name", str(tool_name))
882
+
883
+ tool_call_id = _extract_tool_call_id_from_inputs(inputs) or _pop_pending_tool_call_id(str(tool_name))
884
+ if tool_call_id:
885
+ span.set_attribute("gen_ai.tool.call.id", tool_call_id)
886
+
887
+ span.set_attribute("gen_ai.conversation.id", session_id)
888
+
889
+ if self._sdk.capture_content and input_str:
890
+ span.set_attribute(
891
+ "gen_ai.tool.call.arguments",
892
+ json.dumps(input_str, default=str)[:8192],
893
+ )
894
+
895
+ _safe(set_attrs, site="langchain.on_tool_start.start_attrs")
896
+
897
+ self._runs[key] = _RunState(
898
+ span=span,
899
+ effective_parent_span=span,
900
+ session_id=session_id,
901
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
902
+ nearest_agent_span=self._inherited_agent_span(parent_key),
903
+ kind="tool",
904
+ )
905
+
906
+ def on_tool_end(
907
+ self,
908
+ output: Any,
909
+ *,
910
+ run_id: UUID,
911
+ parent_run_id: Optional[UUID] = None,
912
+ **kwargs: Any,
913
+ ) -> None:
914
+ from struct_sdk.core import _safe
915
+
916
+ r = self._runs.pop(str(run_id), None)
917
+ if not r or not r.span:
918
+ return
919
+ span = r.span
920
+
921
+ def set_result_attr() -> None:
922
+ if self._sdk.capture_content and output is not None:
923
+ span.set_attribute(
924
+ "gen_ai.tool.call.result",
925
+ json.dumps(output, default=str)[:8192],
926
+ )
927
+
928
+ _safe(set_result_attr, site="langchain.on_tool_end.set_result")
929
+ _safe(lambda: span.set_status(StatusCode.OK),
930
+ site="langchain.on_tool_end.set_status")
931
+ _safe(span.end, site="langchain.on_tool_end.span_end")
932
+
933
+ def on_tool_error(
934
+ self,
935
+ error: BaseException,
936
+ *,
937
+ run_id: UUID,
938
+ parent_run_id: Optional[UUID] = None,
939
+ **kwargs: Any,
940
+ ) -> None:
941
+ from struct_sdk.core import _safe
942
+
943
+ r = self._runs.pop(str(run_id), None)
944
+ if not r or not r.span:
945
+ return
946
+ span = r.span
947
+ _safe(lambda: _record_error(span, error),
948
+ site="langchain.on_tool_error.record_error")
949
+ _safe(span.end, site="langchain.on_tool_error.span_end")
950
+
951
+ # ── Retriever ───────────────────────────────────────────────────────────
952
+
953
+ def on_retriever_start(
954
+ self,
955
+ serialized: dict[str, Any],
956
+ query: str,
957
+ *,
958
+ run_id: UUID,
959
+ parent_run_id: Optional[UUID] = None,
960
+ tags: Optional[list[str]] = None,
961
+ metadata: Optional[dict[str, Any]] = None,
962
+ **kwargs: Any,
963
+ ) -> None:
964
+ from struct_sdk.core import _safe
965
+
966
+ key = str(run_id)
967
+ parent_key = str(parent_run_id) if parent_run_id else None
968
+
969
+ name = (
970
+ kwargs.get("name")
971
+ or (serialized.get("name") if isinstance(serialized, dict) else None)
972
+ or _extract_class_name(serialized or {})
973
+ or "retriever"
974
+ )
975
+
976
+ parent = self._resolve_parent(parent_key)
977
+ session_id = self._resolve_session_id(parent_key, metadata)
978
+
979
+ span: Optional[trace.Span] = None
980
+
981
+ def create_span() -> None:
982
+ nonlocal span
983
+ parent_ctx = trace.set_span_in_context(parent.span) if parent.span else None
984
+ span = self._tracer.start_span(
985
+ f"retrieval {name}",
986
+ kind=trace.SpanKind.INTERNAL,
987
+ context=parent_ctx,
988
+ )
989
+
990
+ _safe(create_span, site="langchain.on_retriever_start.create_span")
991
+
992
+ if span is None:
993
+ self._runs[key] = _RunState(
994
+ span=None,
995
+ effective_parent_span=parent.span,
996
+ session_id=session_id,
997
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
998
+ nearest_agent_span=self._inherited_agent_span(parent_key),
999
+ kind="retriever",
1000
+ )
1001
+ return
1002
+
1003
+ def set_attrs() -> None:
1004
+ assert span is not None
1005
+ span.set_attribute("gen_ai.operation.name", "retrieval")
1006
+ span.set_attribute("gen_ai.provider.name", "langchain")
1007
+ span.set_attribute("gen_ai.data_source.id", str(name))
1008
+ span.set_attribute("gen_ai.conversation.id", session_id)
1009
+ if self._sdk.capture_content and query:
1010
+ span.set_attribute("gen_ai.retrieval.query.text", str(query)[:4096])
1011
+
1012
+ _safe(set_attrs, site="langchain.on_retriever_start.start_attrs")
1013
+
1014
+ self._runs[key] = _RunState(
1015
+ span=span,
1016
+ effective_parent_span=span,
1017
+ session_id=session_id,
1018
+ nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
1019
+ nearest_agent_span=self._inherited_agent_span(parent_key),
1020
+ kind="retriever",
1021
+ )
1022
+
1023
+ def on_retriever_end(
1024
+ self,
1025
+ documents: Any,
1026
+ *,
1027
+ run_id: UUID,
1028
+ parent_run_id: Optional[UUID] = None,
1029
+ **kwargs: Any,
1030
+ ) -> None:
1031
+ from struct_sdk.core import _safe
1032
+
1033
+ r = self._runs.pop(str(run_id), None)
1034
+ if not r or not r.span:
1035
+ return
1036
+ span = r.span
1037
+
1038
+ def set_doc_count() -> None:
1039
+ if isinstance(documents, list):
1040
+ span.set_attribute("gen_ai.retrieval.documents", len(documents))
1041
+
1042
+ _safe(set_doc_count, site="langchain.on_retriever_end.set_doc_count")
1043
+ _safe(lambda: span.set_status(StatusCode.OK),
1044
+ site="langchain.on_retriever_end.set_status")
1045
+ _safe(span.end, site="langchain.on_retriever_end.span_end")
1046
+
1047
+ def on_retriever_error(
1048
+ self,
1049
+ error: BaseException,
1050
+ *,
1051
+ run_id: UUID,
1052
+ parent_run_id: Optional[UUID] = None,
1053
+ **kwargs: Any,
1054
+ ) -> None:
1055
+ from struct_sdk.core import _safe
1056
+
1057
+ r = self._runs.pop(str(run_id), None)
1058
+ if not r or not r.span:
1059
+ return
1060
+ span = r.span
1061
+ _safe(lambda: _record_error(span, error),
1062
+ site="langchain.on_retriever_error.record_error")
1063
+ _safe(span.end, site="langchain.on_retriever_error.span_end")
1064
+
1065
+ # ── Internals ───────────────────────────────────────────────────────────
1066
+
1067
+ def _resolve_parent(self, parent_run_id: Optional[str]) -> "_ParentInfo":
1068
+ if parent_run_id:
1069
+ p = self._runs.get(parent_run_id)
1070
+ if p is not None:
1071
+ return _ParentInfo(
1072
+ span=p.effective_parent_span,
1073
+ is_agent=p.kind == "chain",
1074
+ span_session_id=p.session_id,
1075
+ )
1076
+ from struct_sdk.core import _current_agent_span, _current_session_id
1077
+
1078
+ span = _current_agent_span.get(None)
1079
+ return _ParentInfo(
1080
+ span=span,
1081
+ is_agent=span is not None,
1082
+ span_session_id=_current_session_id.get(None),
1083
+ )
1084
+
1085
+ def _find_agent_ancestor(self, parent_run_id: Optional[str]) -> Optional[trace.Span]:
1086
+ """Nearest ``invoke_agent`` ancestor span.
1087
+
1088
+ Used by the chat/LLM handler to propagate the user's most-recent
1089
+ message onto the enclosing agent span's ``gen_ai.input.messages`` so
1090
+ the waterfall UI can show the prompt at the agent level. Reads from
1091
+ the cached ``nearest_agent_span`` populated at RunState creation — the
1092
+ same pattern as ``_inherited_agent_session_id``, O(1) per lookup.
1093
+ """
1094
+ if parent_run_id:
1095
+ r = self._runs.get(parent_run_id)
1096
+ if r is not None and r.nearest_agent_span is not None:
1097
+ return r.nearest_agent_span
1098
+ from struct_sdk.core import _current_agent_span
1099
+ return _current_agent_span.get(None)
1100
+
1101
+ def _inherited_agent_span(self, parent_run_id: Optional[str]) -> Optional[trace.Span]:
1102
+ """Nearest ``invoke_agent`` ancestor span — O(1) lookup.
1103
+
1104
+ Mirrors ``_inherited_agent_session_id`` for the span pointer. Cached
1105
+ at every RunState creation so descendants can reach the ancestor
1106
+ without walking a parent chain (which we don't retain).
1107
+ """
1108
+ if not parent_run_id:
1109
+ return None
1110
+ p = self._runs.get(parent_run_id)
1111
+ return p.nearest_agent_span if p else None
1112
+
1113
+ def _inherited_agent_session_id(self, parent_run_id: Optional[str]) -> Optional[str]:
1114
+ """Nearest ``invoke_agent`` ancestor's gen_ai.conversation.id — O(1) lookup.
1115
+
1116
+ Every run records ``nearest_agent_session_id`` at creation (its own
1117
+ session if it IS an agent, else inherited from parent). A subagent's
1118
+ chain-start uses this to set ``struct.agent.parent_session_id`` on
1119
+ the new agent span even when the immediate parent is a tool / LLM /
1120
+ filtered chain.
1121
+ """
1122
+ if not parent_run_id:
1123
+ return None
1124
+ p = self._runs.get(parent_run_id)
1125
+ return p.nearest_agent_session_id if p else None
1126
+
1127
+ def _resolve_session_id(
1128
+ self,
1129
+ parent_run_id: Optional[str],
1130
+ metadata: Optional[dict[str, Any]],
1131
+ ) -> str:
1132
+ """For chat/tool/retriever spans — inherit from parent so everything rolls up."""
1133
+ if parent_run_id:
1134
+ p = self._runs.get(parent_run_id)
1135
+ if p and p.session_id:
1136
+ return p.session_id
1137
+ if metadata and isinstance(metadata.get("thread_id"), str) and metadata["thread_id"]:
1138
+ return metadata["thread_id"]
1139
+ from struct_sdk.core import _current_session_id
1140
+ ambient = _current_session_id.get(None)
1141
+ if ambient:
1142
+ return ambient
1143
+ return str(uuid.uuid4())
1144
+
1145
+ def _resolve_agent_session_id(
1146
+ self,
1147
+ metadata: Optional[dict[str, Any]],
1148
+ parent_agent_session_id: Optional[str] = None,
1149
+ ) -> str:
1150
+ """For AGENT spans — each invocation gets its own conversation id.
1151
+
1152
+ Prefer config-supplied thread_id for multi-turn continuity, fall
1153
+ back to a fresh UUID. Never inherit from the parent run —
1154
+ subagents should surface as separate sessions in the UI.
1155
+
1156
+ LangChain quirk: when a nested invoke runs inside a parent call,
1157
+ LangChain inherits the parent's metadata.thread_id onto the child
1158
+ even if the child supplied its own. Detect that by comparing
1159
+ against the nearest-agent-ancestor's session and assign a fresh
1160
+ UUID if they match.
1161
+ """
1162
+ thread_id = metadata.get("thread_id") if metadata else None
1163
+ if isinstance(thread_id, str) and thread_id:
1164
+ if parent_agent_session_id and thread_id == parent_agent_session_id:
1165
+ return str(uuid.uuid4())
1166
+ return thread_id
1167
+ from struct_sdk.core import _current_session_id
1168
+ ambient = _current_session_id.get(None)
1169
+ if ambient:
1170
+ return ambient
1171
+ return str(uuid.uuid4())
1172
+
1173
+
1174
+ # ---------------------------------------------------------------------------
1175
+ # Internal dataclasses + shared helpers
1176
+ # ---------------------------------------------------------------------------
1177
+
1178
+
1179
+ class _RunState:
1180
+ """Per-runId bookkeeping used by StructCallbackHandler."""
1181
+
1182
+ __slots__ = (
1183
+ "span",
1184
+ "effective_parent_span",
1185
+ "session_id",
1186
+ "nearest_agent_session_id",
1187
+ "nearest_agent_span",
1188
+ "kind",
1189
+ )
1190
+
1191
+ def __init__(
1192
+ self,
1193
+ *,
1194
+ span: Optional[trace.Span],
1195
+ effective_parent_span: Optional[trace.Span],
1196
+ session_id: str,
1197
+ nearest_agent_session_id: Optional[str],
1198
+ nearest_agent_span: Optional[trace.Span],
1199
+ kind: str,
1200
+ ) -> None:
1201
+ self.span = span
1202
+ self.effective_parent_span = effective_parent_span
1203
+ self.session_id = session_id
1204
+ self.nearest_agent_session_id = nearest_agent_session_id
1205
+ self.nearest_agent_span = nearest_agent_span
1206
+ self.kind = kind
1207
+
1208
+
1209
+ class _ParentInfo:
1210
+ __slots__ = ("span", "is_agent", "span_session_id")
1211
+
1212
+ def __init__(
1213
+ self,
1214
+ *,
1215
+ span: Optional[trace.Span],
1216
+ is_agent: bool,
1217
+ span_session_id: Optional[str],
1218
+ ) -> None:
1219
+ self.span = span
1220
+ self.is_agent = is_agent
1221
+ self.span_session_id = span_session_id
1222
+
1223
+
1224
+ def _record_error(span: trace.Span, err: BaseException) -> None:
1225
+ span.set_attribute("error.type", type(err).__name__)
1226
+ span.set_status(StatusCode.ERROR, str(err))
1227
+ span.record_exception(err)
1228
+
1229
+
1230
+ def _set_request_attrs(span: trace.Span, invocation: dict[str, Any]) -> None:
1231
+ mapping = [
1232
+ ("temperature", "gen_ai.request.temperature"),
1233
+ ("max_tokens", "gen_ai.request.max_tokens"),
1234
+ ("maxTokens", "gen_ai.request.max_tokens"),
1235
+ ("top_p", "gen_ai.request.top_p"),
1236
+ ("topP", "gen_ai.request.top_p"),
1237
+ ("top_k", "gen_ai.request.top_k"),
1238
+ ("topK", "gen_ai.request.top_k"),
1239
+ ("frequency_penalty", "gen_ai.request.frequency_penalty"),
1240
+ ("presence_penalty", "gen_ai.request.presence_penalty"),
1241
+ ]
1242
+ for src, dst in mapping:
1243
+ val = invocation.get(src)
1244
+ if isinstance(val, (int, float)):
1245
+ span.set_attribute(dst, val)
1246
+ stop = invocation.get("stop") or invocation.get("stop_sequences")
1247
+ if isinstance(stop, list) and stop:
1248
+ span.set_attribute("gen_ai.request.stop_sequences", stop)
1249
+
1250
+
1251
+ def _set_llm_response_attrs(
1252
+ span: trace.Span,
1253
+ sdk: StructSDK,
1254
+ otel_logger: Any,
1255
+ message: Any,
1256
+ provider: Optional[str],
1257
+ session_id: str,
1258
+ ) -> None:
1259
+ usage = getattr(message, "usage_metadata", None) or {}
1260
+ if isinstance(usage, dict):
1261
+ if isinstance(usage.get("input_tokens"), int):
1262
+ span.set_attribute("gen_ai.usage.input_tokens", usage["input_tokens"])
1263
+ if isinstance(usage.get("output_tokens"), int):
1264
+ span.set_attribute("gen_ai.usage.output_tokens", usage["output_tokens"])
1265
+ details = usage.get("input_token_details") or {}
1266
+ cr = details.get("cache_read")
1267
+ cc = details.get("cache_creation")
1268
+ if isinstance(cr, int) and cr > 0:
1269
+ span.set_attribute("gen_ai.usage.cache_read.input_tokens", cr)
1270
+ if isinstance(cc, int) and cc > 0:
1271
+ span.set_attribute("gen_ai.usage.cache_creation.input_tokens", cc)
1272
+
1273
+ resp_meta = getattr(message, "response_metadata", None) or {}
1274
+ resp_model = resp_meta.get("model_name") or resp_meta.get("model")
1275
+ if isinstance(resp_model, str):
1276
+ span.set_attribute("gen_ai.response.model", resp_model)
1277
+
1278
+ finish = resp_meta.get("finish_reason") or resp_meta.get("stop_reason")
1279
+ if isinstance(finish, str):
1280
+ mapped = _LANGCHAIN_FINISH_REASON_MAP.get(finish, finish)
1281
+ span.set_attribute("gen_ai.response.finish_reasons", [mapped])
1282
+
1283
+ resp_id = getattr(message, "id", None) or resp_meta.get("id")
1284
+ if isinstance(resp_id, str):
1285
+ span.set_attribute("gen_ai.response.id", resp_id)
1286
+
1287
+ if sdk.emit_events and otel_logger:
1288
+ _emit_choice_event(otel_logger, message, provider or "langchain", session_id, span)
1289
+ if sdk.emit_span_content:
1290
+ span.set_attribute("gen_ai.output.messages", _to_output_messages(message))
1291
+
1292
+
1293
+ def _extract_tool_call_id_from_inputs(inputs: Optional[dict[str, Any]]) -> Optional[str]:
1294
+ if not isinstance(inputs, dict):
1295
+ return None
1296
+ id_val = inputs.get("id")
1297
+ if isinstance(id_val, str) and id_val:
1298
+ return id_val
1299
+ return None
1300
+
1301
+
1302
+ def _push_pending_tool_calls(pairs: list[tuple[str, str]]) -> None:
1303
+ from struct_sdk.core import _pending_tool_calls
1304
+ pending = _pending_tool_calls.get()
1305
+ if pending is None:
1306
+ pending = {}
1307
+ _pending_tool_calls.set(pending)
1308
+ for name, call_id in pairs:
1309
+ pending.setdefault(name, []).append(call_id)
1310
+
1311
+
1312
+ def _pop_pending_tool_call_id(name: str) -> Optional[str]:
1313
+ from struct_sdk.core import _pending_tool_calls
1314
+ pending = _pending_tool_calls.get(None)
1315
+ if not pending:
1316
+ return None
1317
+ ids = pending.get(name)
1318
+ if not ids:
1319
+ return None
1320
+ return ids.pop(0)
1321
+
1322
+
1323
+ def _propagate_user_prompt(agent_span: trace.Span, messages: list[Any]) -> None:
1324
+ try:
1325
+ existing = None
1326
+ if hasattr(agent_span, "attributes"):
1327
+ existing = agent_span.attributes.get("gen_ai.input.messages") # type: ignore[union-attr]
1328
+ if existing:
1329
+ return
1330
+ parts = _last_user_parts(messages)
1331
+ if parts is None:
1332
+ return
1333
+ agent_span.set_attribute(
1334
+ "gen_ai.input.messages",
1335
+ _truncate_and_serialize([{"role": "user", "parts": parts}]),
1336
+ )
1337
+ except Exception: # noqa: BLE001
1338
+ pass
1339
+
1340
+
1341
+ def _to_input_messages(messages: list[Any]) -> str:
1342
+ try:
1343
+ out = [{"role": role, "parts": parts} for role, parts in (_message_to_role_and_parts(m) for m in messages)]
1344
+ return _truncate_and_serialize(out)
1345
+ except Exception: # noqa: BLE001
1346
+ return "[]"
1347
+
1348
+
1349
+ def _to_output_messages(message: Any) -> str:
1350
+ try:
1351
+ _role, parts = _message_to_role_and_parts(message)
1352
+ resp_meta = getattr(message, "response_metadata", None) or {}
1353
+ finish = resp_meta.get("finish_reason") or resp_meta.get("stop_reason")
1354
+ msg: dict[str, Any] = {"role": "assistant", "parts": parts}
1355
+ if isinstance(finish, str):
1356
+ msg["finish_reason"] = _LANGCHAIN_FINISH_REASON_MAP.get(finish, finish)
1357
+ return _truncate_and_serialize([msg])
1358
+ except Exception: # noqa: BLE001
1359
+ return "[]"
1360
+
1361
+
1362
+ _EVENT_NAME_MAP = {
1363
+ "user": "gen_ai.user.message",
1364
+ "assistant": "gen_ai.assistant.message",
1365
+ "system": "gen_ai.system.message",
1366
+ "tool": "gen_ai.tool.message",
1367
+ }
1368
+
1369
+
1370
+ def _emit_message_events(
1371
+ otel_logger: Any,
1372
+ messages: list[Any],
1373
+ provider: str,
1374
+ session_id: str,
1375
+ span: Optional[trace.Span] = None,
1376
+ ) -> None:
1377
+ try:
1378
+ from opentelemetry._logs import LogRecord, SeverityNumber
1379
+
1380
+ # The callback handler doesn't run inside a `with
1381
+ # use_span(...)` block, so `trace.get_current_span()` is NOT
1382
+ # guaranteed to return the span we just created. Always prefer the
1383
+ # explicitly-passed span; fall back to the ambient lookup only for
1384
+ # backward compatibility with any out-of-tree callers.
1385
+ span_ctx = (span or trace.get_current_span()).get_span_context()
1386
+ for idx, msg in enumerate(messages):
1387
+ role, parts = _message_to_role_and_parts(msg)
1388
+ event_name = _EVENT_NAME_MAP.get(role, f"gen_ai.{role}.message")
1389
+ payload = json.dumps({"role": role, "parts": _truncate_parts(parts)}, default=str)
1390
+ # OTel logs convention: Body holds the event tag (human-readable
1391
+ # signal), the structured JSON payload lives on `attributes.body`.
1392
+ attrs: dict[str, Any] = {
1393
+ "event.name": event_name,
1394
+ "body": payload,
1395
+ "gen_ai.system": provider,
1396
+ "gen_ai.message.index": idx,
1397
+ "gen_ai.conversation.id": session_id,
1398
+ }
1399
+ otel_logger.emit(LogRecord(
1400
+ timestamp=int(time.time_ns()),
1401
+ trace_id=span_ctx.trace_id,
1402
+ span_id=span_ctx.span_id,
1403
+ trace_flags=span_ctx.trace_flags,
1404
+ severity_number=SeverityNumber.INFO,
1405
+ body=event_name,
1406
+ attributes=attrs,
1407
+ ))
1408
+ except Exception: # noqa: BLE001
1409
+ logger.debug("failed to emit message events", exc_info=True)
1410
+
1411
+
1412
+ def _emit_choice_event(
1413
+ otel_logger: Any,
1414
+ message: Any,
1415
+ provider: str,
1416
+ session_id: str,
1417
+ span: Optional[trace.Span] = None,
1418
+ ) -> None:
1419
+ try:
1420
+ from opentelemetry._logs import LogRecord, SeverityNumber
1421
+
1422
+ span_ctx = (span or trace.get_current_span()).get_span_context()
1423
+ _role, parts = _message_to_role_and_parts(message)
1424
+ resp_meta = getattr(message, "response_metadata", None) or {}
1425
+ finish = resp_meta.get("finish_reason") or resp_meta.get("stop_reason") or "stop"
1426
+ mapped = _LANGCHAIN_FINISH_REASON_MAP.get(finish, finish)
1427
+ payload = json.dumps({
1428
+ "index": 0,
1429
+ "finish_reason": mapped,
1430
+ "message": {"role": "assistant", "parts": _truncate_parts(parts)},
1431
+ }, default=str)
1432
+ event_name = "gen_ai.choice"
1433
+ # OTel logs convention: tag on Body, JSON payload on attributes.body.
1434
+ attrs: dict[str, Any] = {
1435
+ "event.name": event_name,
1436
+ "body": payload,
1437
+ "gen_ai.system": provider,
1438
+ "gen_ai.conversation.id": session_id,
1439
+ }
1440
+ otel_logger.emit(LogRecord(
1441
+ timestamp=int(time.time_ns()),
1442
+ trace_id=span_ctx.trace_id,
1443
+ span_id=span_ctx.span_id,
1444
+ trace_flags=span_ctx.trace_flags,
1445
+ severity_number=SeverityNumber.INFO,
1446
+ body=event_name,
1447
+ attributes=attrs,
1448
+ ))
1449
+ except Exception: # noqa: BLE001
1450
+ logger.debug("failed to emit choice event", exc_info=True)