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.
- struct_sdk/__init__.py +14 -0
- struct_sdk/anthropic.py +938 -0
- struct_sdk/claude_agent.py +85 -0
- struct_sdk/core.py +755 -0
- struct_sdk/langchain.py +1450 -0
- struct_sdk-0.1.0.dist-info/METADATA +333 -0
- struct_sdk-0.1.0.dist-info/RECORD +9 -0
- struct_sdk-0.1.0.dist-info/WHEEL +4 -0
- struct_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
struct_sdk/langchain.py
ADDED
|
@@ -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)
|