traceAI-langchain 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.3
2
+ Name: traceAI-langchain
3
+ Version: 0.1.0
4
+ Summary: OpenTelemetry instrumentation for Langchain
5
+ Author: Future AGI
6
+ Author-email: no-reply@futureagi.com
7
+ Requires-Python: >3.9,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Dist: fi-instrumentation-otel
14
+ Requires-Dist: langchain-community[all] (>=0.3.9,<0.4.0)
15
+ Requires-Dist: langchain-core (>=0.2.43)
16
+ Requires-Dist: langchain[all] (>=0.3.9,<0.4.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+
File without changes
@@ -0,0 +1,22 @@
1
+ [tool.poetry]
2
+ name = "traceAI-langchain"
3
+ version = "0.1.0"
4
+ description = "OpenTelemetry instrumentation for Langchain"
5
+ authors = ["Future AGI <no-reply@futureagi.com>"]
6
+ readme = "README.md"
7
+
8
+ packages = [
9
+ { include = "traceai_langchain" },
10
+ ]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = ">3.9, <4.0"
14
+ fi-instrumentation-otel = "*"
15
+ langchain = {version = "^0.3.9", extras = ["all"]}
16
+ langchain-community = {version = "^0.3.9", extras = ["all"]}
17
+ langchain-core = ">=0.2.43"
18
+
19
+
20
+ [build-system]
21
+ requires = ["poetry-core"]
22
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,146 @@
1
+ import logging
2
+ from typing import Any, Callable, Collection, List, Optional
3
+ from uuid import UUID
4
+
5
+ import langchain_core
6
+ from fi_instrumentation import FITracer, TraceConfig
7
+ from langchain_core.callbacks import BaseCallbackManager
8
+ from langchain_core.runnables.config import var_child_runnable_config # noqa F401
9
+ from opentelemetry import trace as trace_api
10
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore
11
+ from opentelemetry.trace import Span
12
+ from traceai_langchain._tracer import FiTracer
13
+ from traceai_langchain.package import _instruments
14
+ from traceai_langchain.version import __version__
15
+ from wrapt import wrap_function_wrapper # type: ignore
16
+
17
+ logger = logging.getLogger(__name__)
18
+ logger.addHandler(logging.NullHandler())
19
+
20
+ _MODULE = "langchain_core"
21
+
22
+
23
+ class LangChainInstrumentor(BaseInstrumentor): # type: ignore
24
+ """
25
+ An instrumentor for LangChain
26
+ """
27
+
28
+ def instrumentation_dependencies(self) -> Collection[str]:
29
+ return _instruments
30
+
31
+ def _instrument(self, **kwargs: Any) -> None:
32
+ if not (tracer_provider := kwargs.get("tracer_provider")):
33
+ tracer_provider = trace_api.get_tracer_provider()
34
+ if not (config := kwargs.get("config")):
35
+ config = TraceConfig()
36
+ else:
37
+ assert isinstance(config, TraceConfig)
38
+ import langchain_core
39
+ from traceai_langchain._tracer import FiTracer
40
+
41
+ tracer = FITracer(
42
+ trace_api.get_tracer(__name__, __version__, tracer_provider),
43
+ config=config,
44
+ )
45
+ self._tracer: Optional[FiTracer] = FiTracer(tracer)
46
+ self._original_callback_manager_init = BaseCallbackManager.__init__
47
+ wrap_function_wrapper(
48
+ module="langchain_core.callbacks",
49
+ name="BaseCallbackManager.__init__",
50
+ wrapper=_BaseCallbackManagerInit(self._tracer),
51
+ )
52
+
53
+ def _uninstrument(self, **kwargs: Any) -> None:
54
+ langchain_core.callbacks.BaseCallbackManager.__init__ = self._original_callback_manager_init # type: ignore
55
+ self._original_callback_manager_init = None # type: ignore
56
+ self._tracer = None
57
+
58
+ def get_span(self, run_id: UUID) -> Optional[Span]:
59
+ return self._tracer.get_span(run_id) if self._tracer else None
60
+
61
+ def get_ancestors(self, run_id: UUID) -> List[Span]:
62
+ ancestors: List[Span] = []
63
+ tracer = self._tracer
64
+ assert tracer
65
+
66
+ run = tracer.run_map.get(str(run_id))
67
+ if not run:
68
+ return ancestors
69
+
70
+ ancestor_run_id = run.parent_run_id # start with the first ancestor
71
+
72
+ while ancestor_run_id:
73
+ span = self.get_span(ancestor_run_id)
74
+ if span:
75
+ ancestors.append(span)
76
+
77
+ run = tracer.run_map.get(str(ancestor_run_id))
78
+ if not run:
79
+ break
80
+ ancestor_run_id = run.parent_run_id
81
+ return ancestors
82
+
83
+
84
+ class _BaseCallbackManagerInit:
85
+ __slots__ = ("_tracer",)
86
+
87
+ def __init__(self, tracer: "FiTracer"):
88
+ self._tracer = tracer
89
+
90
+ def __call__(
91
+ self,
92
+ wrapped: Callable[..., None],
93
+ instance: "BaseCallbackManager",
94
+ args: Any,
95
+ kwargs: Any,
96
+ ) -> None:
97
+ wrapped(*args, **kwargs)
98
+ for handler in instance.inheritable_handlers:
99
+ # Handlers may be copied when new managers are created, so we
100
+ # don't want to keep adding. E.g. see the following location.
101
+ # https://github.com/langchain-ai/langchain/blob/5c2538b9f7fb64afed2a918b621d9d8681c7ae32/libs/core/langchain_core/callbacks/manager.py#L1876 # noqa: E501
102
+ if isinstance(handler, type(self._tracer)):
103
+ break
104
+ else:
105
+ instance.add_handler(self._tracer, True)
106
+
107
+
108
+ def get_current_span() -> Optional[Span]:
109
+ import langchain_core
110
+
111
+ run_id: Optional[UUID] = None
112
+ config = langchain_core.runnables.config.var_child_runnable_config.get()
113
+ if not isinstance(config, dict):
114
+ return None
115
+ for v in config.values():
116
+ if not isinstance(v, langchain_core.callbacks.BaseCallbackManager):
117
+ continue
118
+ if run_id := v.parent_run_id:
119
+ break
120
+ if not run_id:
121
+ return None
122
+ return LangChainInstrumentor().get_span(run_id)
123
+
124
+
125
+ def get_ancestor_spans() -> List[Span]:
126
+ """
127
+ Retrieve the ancestor spans for the current LangChain run.
128
+
129
+ This function traverses the LangChain run tree from the current run's parent up to the root,
130
+ collecting the spans associated with each ancestor run. The list is ordered from the immediate
131
+ parent of the current run to the root of the run tree.
132
+ """
133
+ import langchain_core
134
+
135
+ run_id: Optional[UUID] = None
136
+ config = langchain_core.runnables.config.var_child_runnable_config.get()
137
+ if not isinstance(config, dict):
138
+ return None
139
+ for v in config.values():
140
+ if not isinstance(v, langchain_core.callbacks.BaseCallbackManager):
141
+ continue
142
+ if run_id := v.parent_run_id:
143
+ break
144
+ if not run_id:
145
+ return []
146
+ return LangChainInstrumentor().get_ancestors(run_id)
@@ -0,0 +1,1083 @@
1
+ import json
2
+ import logging
3
+ import math
4
+ import time
5
+ import traceback
6
+ from copy import deepcopy
7
+ from datetime import datetime, timezone
8
+ from enum import Enum
9
+ from itertools import chain
10
+ from threading import RLock
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ Generic,
17
+ Hashable,
18
+ Iterable,
19
+ Iterator,
20
+ List,
21
+ Mapping,
22
+ Optional,
23
+ Sequence,
24
+ Tuple,
25
+ TypeVar,
26
+ cast,
27
+ )
28
+ from uuid import UUID
29
+
30
+ import wrapt # type: ignore
31
+ from fi_instrumentation import get_attributes_from_context, safe_json_dumps
32
+ from fi_instrumentation.fi_types import (
33
+ DocumentAttributes,
34
+ EmbeddingAttributes,
35
+ FiMimeTypeValues,
36
+ FiSpanKindValues,
37
+ ImageAttributes,
38
+ MessageAttributes,
39
+ MessageContentAttributes,
40
+ RerankerAttributes,
41
+ SpanAttributes,
42
+ ToolCallAttributes,
43
+ )
44
+ from langchain_core.messages import BaseMessage
45
+ from langchain_core.messages.human import HumanMessage
46
+ from langchain_core.tracers import BaseTracer, LangChainTracer
47
+ from langchain_core.tracers.schemas import Run
48
+ from opentelemetry import context as context_api
49
+ from opentelemetry import trace as trace_api
50
+ from opentelemetry.context import _SUPPRESS_INSTRUMENTATION_KEY
51
+ from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes
52
+ from opentelemetry.trace import Span
53
+ from opentelemetry.util.types import AttributeValue
54
+ from wrapt import ObjectProxy
55
+
56
+ logger = logging.getLogger(__name__)
57
+ logger.addHandler(logging.NullHandler())
58
+
59
+ _AUDIT_TIMING = False
60
+
61
+
62
+ @wrapt.decorator # type: ignore
63
+ def audit_timing(wrapped: Any, _: Any, args: Any, kwargs: Any) -> Any:
64
+ if not _AUDIT_TIMING:
65
+ return wrapped(*args, **kwargs)
66
+ start_time = time.perf_counter()
67
+ try:
68
+ return wrapped(*args, **kwargs)
69
+ finally:
70
+ latency_ms = (time.perf_counter() - start_time) * 1000
71
+ print(f"{wrapped.__name__}: {latency_ms:.2f}ms")
72
+
73
+
74
+ K = TypeVar("K", bound=Hashable)
75
+ V = TypeVar("V")
76
+
77
+
78
+ class _DictWithLock(ObjectProxy, Generic[K, V]): # type: ignore
79
+ """
80
+ A wrapped dictionary with lock
81
+ """
82
+
83
+ def __init__(self, wrapped: Optional[Dict[str, V]] = None) -> None:
84
+ super().__init__(wrapped or {})
85
+ self._self_lock = RLock()
86
+
87
+ def get(self, key: K) -> Optional[V]:
88
+ with self._self_lock:
89
+ return cast(Optional[V], self.__wrapped__.get(key))
90
+
91
+ def pop(self, key: K, *args: Any) -> Optional[V]:
92
+ with self._self_lock:
93
+ return cast(Optional[V], self.__wrapped__.pop(key, *args))
94
+
95
+ def __getitem__(self, key: K) -> V:
96
+ with self._self_lock:
97
+ return cast(V, super().__getitem__(key))
98
+
99
+ def __setitem__(self, key: K, value: V) -> None:
100
+ with self._self_lock:
101
+ super().__setitem__(key, value)
102
+
103
+ def __delitem__(self, key: K) -> None:
104
+ with self._self_lock:
105
+ super().__delitem__(key)
106
+
107
+
108
+ class FiTracer(BaseTracer):
109
+ __slots__ = ("_tracer", "_spans_by_run")
110
+
111
+ def _extract_content(self, data: Any) -> Any:
112
+ """Extract clean content from various message formats"""
113
+ if isinstance(data, dict):
114
+ # Handle dictionary format
115
+ if "content" in data:
116
+ return data["content"]
117
+ if "generations" in data and isinstance(data["generations"], list):
118
+ for gen in data["generations"][0]: # Take first generation
119
+ if "text" in gen:
120
+ return gen["text"]
121
+ if "message" in gen and isinstance(gen["message"], dict):
122
+ return gen["message"].get("content")
123
+ if "messages" in data and isinstance(data["messages"], list):
124
+ messages_content = []
125
+ for msg_list in data["messages"]:
126
+ if isinstance(msg_list, list):
127
+ for msg in msg_list:
128
+ if isinstance(msg, dict) and "kwargs" in msg:
129
+ content = msg["kwargs"].get("content")
130
+ if content:
131
+ messages_content.append(content)
132
+ elif isinstance(msg_list, HumanMessage):
133
+ messages_content.append(msg_list.content)
134
+ return (
135
+ messages_content[0]
136
+ if len(messages_content) == 1
137
+ else messages_content
138
+ )
139
+ if "input" in data:
140
+ return data["input"]
141
+ if "query" in data:
142
+ return data["query"]
143
+ return data
144
+ elif hasattr(data, "content"):
145
+ # Handle message objects
146
+ return data.content
147
+ elif isinstance(data, (list, tuple)):
148
+ # Handle list format
149
+ if len(data) == 1:
150
+ return self._extract_content(data[0])
151
+ return [self._extract_content(item) for item in data]
152
+ return data
153
+
154
+ def _send_span_data_to_api(
155
+ self, span: Span, input_data: Any, output_data: Any
156
+ ) -> None:
157
+ """Send span data to API and add to span attributes"""
158
+ try:
159
+ # cleaned_input = self._extract_content(input_data)
160
+ cleaned_output = self._extract_content(output_data)
161
+
162
+ span_data = {
163
+ "span_id": span.context.span_id,
164
+ # "input": cleaned_input,
165
+ "output": cleaned_output,
166
+ }
167
+
168
+ # if cleaned_input is not None:
169
+ # span.set_attributes({"fi.llm.input": str(cleaned_input)})
170
+ if cleaned_output is not None:
171
+ span.set_attributes({"fi.llm.output": str(cleaned_output)})
172
+
173
+ except Exception as e:
174
+ logger.exception(f"Error sending span data to API: {e}")
175
+
176
+ def __init__(self, tracer: trace_api.Tracer, *args: Any, **kwargs: Any) -> None:
177
+ super().__init__(*args, **kwargs)
178
+ if TYPE_CHECKING:
179
+ # check that `run_map` still exists in parent class
180
+ assert self.run_map
181
+ self.run_map = _DictWithLock[str, Run](self.run_map)
182
+ self._tracer = tracer
183
+ self._spans_by_run: Dict[UUID, Span] = _DictWithLock[UUID, Span]()
184
+ self._lock = RLock() # handlers may be run in a thread by langchain
185
+
186
+ def get_span(self, run_id: UUID) -> Optional[Span]:
187
+ return self._spans_by_run.get(run_id)
188
+
189
+ @audit_timing # type: ignore
190
+ def _start_trace(self, run: Run) -> None:
191
+ self.run_map[str(run.id)] = run
192
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
193
+ return
194
+ with self._lock:
195
+ parent_context = (
196
+ trace_api.set_span_in_context(parent)
197
+ if (parent_run_id := run.parent_run_id)
198
+ and (parent := self._spans_by_run.get(parent_run_id))
199
+ else None
200
+ )
201
+ # We can't use real time because the handler may be
202
+ # called in a background thread.
203
+ start_time_utc_nano = _as_utc_nano(run.start_time)
204
+ span = self._tracer.start_span(
205
+ name=run.name,
206
+ context=parent_context,
207
+ start_time=start_time_utc_nano,
208
+ )
209
+
210
+ # The following line of code is commented out to serve as a reminder that in a system
211
+ # of callbacks, attaching the context can be hazardous because there is no guarantee
212
+ # that the context will be detached. An error could happen between callbacks leaving
213
+ # the context attached forever, and all future spans will use it as parent. What's
214
+ # worse is that the error could have also prevented the span from being exported,
215
+ # leaving all future spans as orphans. That is a very bad scenario.
216
+ # token = context_api.attach(context)
217
+ with self._lock:
218
+ self._spans_by_run[run.id] = span
219
+
220
+ # Send initial span data with input
221
+ input_data = None
222
+ if run.inputs:
223
+ if isinstance(run.inputs, dict):
224
+ input_data = (
225
+ run.inputs.get("input") or run.inputs.get("query") or run.inputs
226
+ )
227
+ else:
228
+ input_data = run.inputs
229
+
230
+ # self._send_span_data_to_api(
231
+ # span_id=format(span.context.span_id, "016x"),
232
+ # input_data=input_data,
233
+ # output_data=None # Output not available at start
234
+ # )
235
+
236
+ @audit_timing # type: ignore
237
+ def _end_trace(self, run: Run) -> None:
238
+ self.run_map.pop(str(run.id), None)
239
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
240
+ return
241
+ span = self._spans_by_run.pop(run.id, None)
242
+ if span:
243
+ try:
244
+ _update_span(span, run)
245
+
246
+ # Send final span data with both input and output
247
+ output_data = None
248
+ if run.outputs:
249
+ if isinstance(run.outputs, dict):
250
+ output_data = (
251
+ run.outputs.get("output")
252
+ or run.outputs.get("result")
253
+ or run.outputs
254
+ )
255
+ else:
256
+ output_data = run.outputs
257
+
258
+ self._send_span_data_to_api(
259
+ span=span, input_data=run.inputs, output_data=output_data
260
+ )
261
+
262
+ except Exception:
263
+ logger.exception("Failed to update span with run data.")
264
+ # We can't use real time because the handler may be
265
+ # called in a background thread.
266
+ end_time_utc_nano = _as_utc_nano(run.end_time) if run.end_time else None
267
+ span.end(end_time=end_time_utc_nano)
268
+
269
+ def _persist_run(self, run: Run) -> None:
270
+ pass
271
+
272
+ def on_llm_error(
273
+ self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any
274
+ ) -> Run:
275
+ if span := self._spans_by_run.get(run_id):
276
+ _record_exception(span, error)
277
+ # Send error span data
278
+ self._send_span_data_to_api(
279
+ span=span,
280
+ input_data=kwargs.get("inputs"),
281
+ output_data={"error": str(error)},
282
+ )
283
+ return super().on_llm_error(error, *args, run_id=run_id, **kwargs)
284
+
285
+ def on_chain_error(
286
+ self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any
287
+ ) -> Run:
288
+ if span := self._spans_by_run.get(run_id):
289
+ _record_exception(span, error)
290
+ # Send error span data
291
+ self._send_span_data_to_api(
292
+ span=span,
293
+ input_data=kwargs.get("inputs"),
294
+ output_data={"error": str(error)},
295
+ )
296
+ return super().on_chain_error(error, *args, run_id=run_id, **kwargs)
297
+
298
+ def on_retriever_error(
299
+ self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any
300
+ ) -> Run:
301
+ if span := self._spans_by_run.get(run_id):
302
+ _record_exception(span, error)
303
+ # Send error span data
304
+ self._send_span_data_to_api(
305
+ span=span,
306
+ input_data=kwargs.get("inputs"),
307
+ output_data={"error": str(error)},
308
+ )
309
+ return super().on_retriever_error(error, *args, run_id=run_id, **kwargs)
310
+
311
+ def on_tool_error(
312
+ self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any
313
+ ) -> Run:
314
+ if span := self._spans_by_run.get(run_id):
315
+ _record_exception(span, error)
316
+ # Send error span data
317
+ self._send_span_data_to_api(
318
+ span=span,
319
+ input_data=kwargs.get("inputs"),
320
+ output_data={"error": str(error)},
321
+ )
322
+ return super().on_tool_error(error, *args, run_id=run_id, **kwargs)
323
+
324
+ def on_chat_model_start(self, *args: Any, **kwargs: Any) -> Run:
325
+ """
326
+ This emulates the behavior of the LangChainTracer.
327
+ https://github.com/langchain-ai/langchain/blob/c01467b1f4f9beae8f1edb105b17aa4f36bf6573/libs/core/langchain_core/tracers/langchain.py#L115
328
+
329
+ Although this method exists on the parent class, i.e. `BaseTracer`,
330
+ it requires setting `self._schema_format = "original+chat"`.
331
+ https://github.com/langchain-ai/langchain/blob/c01467b1f4f9beae8f1edb105b17aa4f36bf6573/libs/core/langchain_core/tracers/base.py#L170
332
+
333
+ But currently self._schema_format is marked for internal use.
334
+ https://github.com/langchain-ai/langchain/blob/c01467b1f4f9beae8f1edb105b17aa4f36bf6573/libs/core/langchain_core/tracers/base.py#L60
335
+ """ # noqa: E501
336
+ return LangChainTracer.on_chat_model_start(self, *args, **kwargs) # type: ignore
337
+
338
+
339
+ @audit_timing # type: ignore
340
+ def _record_exception(span: Span, error: BaseException) -> None:
341
+ if isinstance(error, Exception):
342
+ span.record_exception(error)
343
+ return
344
+ exception_type = error.__class__.__name__
345
+ exception_message = str(error)
346
+ if not exception_message:
347
+ exception_message = repr(error)
348
+ attributes: Dict[str, AttributeValue] = {
349
+ OTELSpanAttributes.EXCEPTION_TYPE: exception_type,
350
+ OTELSpanAttributes.EXCEPTION_MESSAGE: exception_message,
351
+ OTELSpanAttributes.EXCEPTION_ESCAPED: False,
352
+ }
353
+ try:
354
+ # See e.g. https://github.com/open-telemetry/opentelemetry-python/blob/e9c7c7529993cd13b4af661e2e3ddac3189a34d0/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py#L967 # noqa: E501
355
+ attributes[OTELSpanAttributes.EXCEPTION_STACKTRACE] = traceback.format_exc()
356
+ except Exception:
357
+ logger.exception("Failed to record exception stacktrace.")
358
+ span.add_event(name="exception", attributes=attributes)
359
+
360
+
361
+ @audit_timing # type: ignore
362
+ def _update_span(span: Span, run: Run) -> None:
363
+ if run.error is None:
364
+ span.set_status(trace_api.StatusCode.OK)
365
+ else:
366
+ span.set_status(trace_api.Status(trace_api.StatusCode.ERROR, run.error))
367
+ span_kind = (
368
+ FiSpanKindValues.AGENT
369
+ if "agent" in run.name.lower()
370
+ else _langchain_run_type_to_span_kind(run.run_type)
371
+ )
372
+ span.set_attribute(FI_SPAN_KIND, span_kind.value)
373
+ span.set_attributes(dict(get_attributes_from_context()))
374
+ filtered_inputs, images, eval_input, query = _filter_images(
375
+ run.inputs.get("messages", [])
376
+ )
377
+
378
+ span.set_attributes(
379
+ dict(
380
+ _flatten(
381
+ chain(
382
+ _as_span_input(filtered_inputs),
383
+ _as_output(_convert_io(run.outputs)),
384
+ _as_input_images(images),
385
+ _as_eval_input(eval_input),
386
+ _prompts(run.inputs),
387
+ _input_messages(run.inputs),
388
+ _output_messages(run.outputs),
389
+ _prompt_template(run),
390
+ _invocation_parameters(run),
391
+ _model_name(run.extra),
392
+ _token_counts(run.outputs),
393
+ _function_calls(run.outputs),
394
+ _tools(run),
395
+ _retrieval_documents(run),
396
+ _metadata(run),
397
+ _as_raw_input_output(run),
398
+ _as_query(query),
399
+ )
400
+ )
401
+ )
402
+ )
403
+
404
+
405
+ def _langchain_run_type_to_span_kind(run_type: str) -> FiSpanKindValues:
406
+ try:
407
+ return FiSpanKindValues(run_type.upper())
408
+ except ValueError:
409
+ return FiSpanKindValues.UNKNOWN
410
+
411
+
412
+ def stop_on_exception(
413
+ wrapped: Callable[..., Iterator[Tuple[str, Any]]],
414
+ ) -> Callable[..., Iterator[Tuple[str, Any]]]:
415
+ def wrapper(*args: Any, **kwargs: Any) -> Iterator[Tuple[str, Any]]:
416
+ start_time = time.perf_counter()
417
+ try:
418
+ yield from wrapped(*args, **kwargs)
419
+ except Exception:
420
+ logger.exception("Failed to get attribute.")
421
+ finally:
422
+ if _AUDIT_TIMING:
423
+ latency_ms = (time.perf_counter() - start_time) * 1000
424
+ print(f"{wrapped.__name__}: {latency_ms:.3f}ms")
425
+
426
+ return wrapper
427
+
428
+
429
+ @stop_on_exception
430
+ def _flatten(
431
+ key_values: Iterable[Tuple[str, Any]]
432
+ ) -> Iterator[Tuple[str, AttributeValue]]:
433
+ for key, value in key_values:
434
+ if value is None:
435
+ continue
436
+ if isinstance(value, Mapping):
437
+ for sub_key, sub_value in _flatten(value.items()):
438
+ yield f"{key}.{sub_key}", sub_value
439
+ elif isinstance(value, List) and any(
440
+ isinstance(item, Mapping) for item in value
441
+ ):
442
+ for index, sub_mapping in enumerate(value):
443
+ for sub_key, sub_value in _flatten(sub_mapping.items()):
444
+ yield f"{key}.{index}.{sub_key}", sub_value
445
+ else:
446
+ if isinstance(value, Enum):
447
+ value = value.value
448
+ yield key, value
449
+
450
+
451
+ @stop_on_exception
452
+ def _as_input(values: Iterable[str]) -> Iterator[Tuple[str, str]]:
453
+ return zip((INPUT_VALUE, INPUT_MIME_TYPE), values)
454
+
455
+
456
+ def _as_span_input(input: List[str]) -> Iterator[Tuple[str, Any]]:
457
+ yield INPUT_VALUE, json.dumps(input)
458
+
459
+
460
+ @stop_on_exception
461
+ def _as_output(values: Iterable[str]) -> Iterator[Tuple[str, str]]:
462
+ return zip((OUTPUT_VALUE, OUTPUT_MIME_TYPE), values)
463
+
464
+
465
+ def _as_input_images(image_urls: List[str]) -> Iterator[Tuple[str, Any]]:
466
+ if image_urls:
467
+ yield INPUT_IMAGES, json.dumps(image_urls)
468
+
469
+
470
+ def _as_eval_input(values: Iterable[str]) -> Iterator[Tuple[str, str]]:
471
+ yield EVAL_INPUT, json.dumps(values)
472
+
473
+
474
+ def _as_raw_input_output(run: Run) -> Iterator[Tuple[str, Any]]:
475
+ if raw_input := run.inputs:
476
+ if hasattr(raw_input, "__dict__"):
477
+ raw_input = raw_input.__dict__
478
+ yield RAW_INPUT, safe_json_dumps(raw_input)
479
+ if raw_output := run.outputs:
480
+ if hasattr(raw_output, "__dict__"):
481
+ raw_output = raw_output.__dict__
482
+ yield RAW_OUTPUT, safe_json_dumps(raw_output)
483
+
484
+
485
+ def _as_query(values: Iterable[str]) -> Iterator[Tuple[str, str]]:
486
+ if values:
487
+ yield QUERY, safe_json_dumps(values)
488
+
489
+
490
+ def _convert_io(obj: Optional[Mapping[str, Any]]) -> Iterator[str]:
491
+ if not obj:
492
+ return
493
+ assert isinstance(obj, dict), f"expected dict, found {type(obj)}"
494
+ if len(obj) == 1 and isinstance(value := next(iter(obj.values())), str):
495
+ yield value
496
+ else:
497
+ obj = dict(_replace_nan(obj))
498
+ yield safe_json_dumps(obj)
499
+ yield FiMimeTypeValues.JSON.value
500
+
501
+
502
+ def _replace_nan(obj: Mapping[str, Any]) -> Iterator[Tuple[str, Any]]:
503
+ for k, v in obj.items():
504
+ if isinstance(v, float) and not math.isfinite(v):
505
+ yield k, None
506
+ else:
507
+ yield k, v
508
+
509
+
510
+ @stop_on_exception
511
+ def _prompts(inputs: Optional[Mapping[str, Any]]) -> Iterator[Tuple[str, List[str]]]:
512
+ """Yields prompts if present."""
513
+ if not inputs:
514
+ return
515
+ assert hasattr(inputs, "get"), f"expected Mapping, found {type(inputs)}"
516
+ if prompts := inputs.get("prompts"):
517
+ yield LLM_PROMPTS, prompts
518
+
519
+
520
+ @stop_on_exception
521
+ def _input_messages(
522
+ inputs: Optional[Mapping[str, Any]],
523
+ ) -> Iterator[Tuple[str, List[Dict[str, Any]]]]:
524
+ """Yields chat messages if present."""
525
+ if not inputs:
526
+ return
527
+ assert hasattr(inputs, "get"), f"expected Mapping, found {type(inputs)}"
528
+ # There may be more than one set of messages. We'll use just the first set.
529
+ if not (multiple_messages := inputs.get("messages")):
530
+ return
531
+ assert isinstance(
532
+ multiple_messages, Iterable
533
+ ), f"expected Iterable, found {type(multiple_messages)}"
534
+ # This will only get the first set of messages.
535
+ if not (first_messages := next(iter(multiple_messages), None)):
536
+ return
537
+ parsed_messages = []
538
+ if isinstance(first_messages, list):
539
+ for message_data in first_messages:
540
+ if isinstance(message_data, BaseMessage):
541
+ parsed_messages.append(
542
+ dict(_parse_message_data(message_data.to_json()))
543
+ )
544
+ elif hasattr(message_data, "get"):
545
+ parsed_messages.append(dict(_parse_message_data(message_data)))
546
+ else:
547
+ raise ValueError(
548
+ f"failed to parse message of type {type(message_data)}"
549
+ )
550
+ elif isinstance(first_messages, BaseMessage):
551
+ parsed_messages.append(dict(_parse_message_data(first_messages.to_json())))
552
+ elif hasattr(first_messages, "get"):
553
+ parsed_messages.append(dict(_parse_message_data(first_messages)))
554
+ else:
555
+ raise ValueError(f"failed to parse messages of type {type(first_messages)}")
556
+ if parsed_messages:
557
+ yield LLM_INPUT_MESSAGES, parsed_messages
558
+
559
+
560
+ @stop_on_exception
561
+ def _output_messages(
562
+ outputs: Optional[Mapping[str, Any]],
563
+ ) -> Iterator[Tuple[str, List[Dict[str, Any]]]]:
564
+ """Yields chat messages if present."""
565
+ if not outputs:
566
+ return
567
+ assert hasattr(outputs, "get"), f"expected Mapping, found {type(outputs)}"
568
+ # There may be more than one set of generations. We'll use just the first set.
569
+ if not (multiple_generations := outputs.get("generations")):
570
+ return
571
+ assert isinstance(
572
+ multiple_generations, Iterable
573
+ ), f"expected Iterable, found {type(multiple_generations)}"
574
+ # This will only get the first set of generations.
575
+ if not (first_generations := next(iter(multiple_generations), None)):
576
+ return
577
+ assert isinstance(
578
+ first_generations, Iterable
579
+ ), f"expected Iterable, found {type(first_generations)}"
580
+ parsed_messages = []
581
+ for generation in first_generations:
582
+ assert hasattr(generation, "get"), f"expected Mapping, found {type(generation)}"
583
+ if message_data := generation.get("message"):
584
+ if isinstance(message_data, BaseMessage):
585
+ parsed_messages.append(
586
+ dict(_parse_message_data(message_data.to_json()))
587
+ )
588
+ elif hasattr(message_data, "get"):
589
+ parsed_messages.append(dict(_parse_message_data(message_data)))
590
+ else:
591
+ raise ValueError(f"fail to parse message of type {type(message_data)}")
592
+ if parsed_messages:
593
+ yield LLM_OUTPUT_MESSAGES, parsed_messages
594
+
595
+
596
+ @stop_on_exception
597
+ def _parse_message_data(
598
+ message_data: Optional[Mapping[str, Any]]
599
+ ) -> Iterator[Tuple[str, Any]]:
600
+ """Parses message data to grab message role, content, etc."""
601
+ if not message_data:
602
+ return
603
+ assert hasattr(message_data, "get"), f"expected Mapping, found {type(message_data)}"
604
+ id_ = message_data.get("id")
605
+ assert isinstance(id_, List), f"expected list, found {type(id_)}"
606
+ message_class_name = id_[-1]
607
+ if message_class_name.startswith("HumanMessage"):
608
+ role = "user"
609
+ elif message_class_name.startswith("AIMessage"):
610
+ role = "assistant"
611
+ elif message_class_name.startswith("SystemMessage"):
612
+ role = "system"
613
+ elif message_class_name.startswith("FunctionMessage"):
614
+ role = "function"
615
+ elif message_class_name.startswith("ToolMessage"):
616
+ role = "tool"
617
+ elif message_class_name.startswith("ChatMessage"):
618
+ role = message_data["kwargs"]["role"]
619
+ else:
620
+ raise ValueError(f"Cannot parse message of type: {message_class_name}")
621
+ yield MESSAGE_ROLE, role
622
+ if kwargs := message_data.get("kwargs"):
623
+ assert hasattr(kwargs, "get"), f"expected Mapping, found {type(kwargs)}"
624
+ if content := kwargs.get("content"):
625
+ if isinstance(content, str):
626
+ yield MESSAGE_CONTENT, content
627
+ elif isinstance(content, list):
628
+ for i, obj in enumerate(content):
629
+ assert hasattr(obj, "get"), f"expected Mapping, found {type(obj)}"
630
+ for k, v in _get_attributes_from_message_content(obj):
631
+ yield f"{MESSAGE_CONTENTS}.{i}.{k}", v
632
+ if additional_kwargs := kwargs.get("additional_kwargs"):
633
+ assert hasattr(
634
+ additional_kwargs, "get"
635
+ ), f"expected Mapping, found {type(additional_kwargs)}"
636
+ if function_call := additional_kwargs.get("function_call"):
637
+ assert hasattr(
638
+ function_call, "get"
639
+ ), f"expected Mapping, found {type(function_call)}"
640
+ if name := function_call.get("name"):
641
+ assert isinstance(name, str), f"expected str, found {type(name)}"
642
+ yield MESSAGE_FUNCTION_CALL_NAME, name
643
+ if arguments := function_call.get("arguments"):
644
+ assert isinstance(
645
+ arguments, str
646
+ ), f"expected str, found {type(arguments)}"
647
+ yield MESSAGE_FUNCTION_CALL_ARGUMENTS_JSON, arguments
648
+ if tool_calls := additional_kwargs.get("tool_calls"):
649
+ assert isinstance(
650
+ tool_calls, Iterable
651
+ ), f"expected Iterable, found {type(tool_calls)}"
652
+ message_tool_calls = []
653
+ for tool_call in tool_calls:
654
+ if message_tool_call := dict(_get_tool_call(tool_call)):
655
+ message_tool_calls.append(message_tool_call)
656
+ if message_tool_calls:
657
+ yield MESSAGE_TOOL_CALLS, message_tool_calls
658
+
659
+
660
+ @stop_on_exception
661
+ def _get_tool_call(tool_call: Optional[Mapping[str, Any]]) -> Iterator[Tuple[str, Any]]:
662
+ if not tool_call:
663
+ return
664
+ assert hasattr(tool_call, "get"), f"expected Mapping, found {type(tool_call)}"
665
+ if function := tool_call.get("function"):
666
+ assert hasattr(function, "get"), f"expected Mapping, found {type(function)}"
667
+ if name := function.get("name"):
668
+ assert isinstance(name, str), f"expected str, found {type(name)}"
669
+ yield TOOL_CALL_FUNCTION_NAME, name
670
+ if arguments := function.get("arguments"):
671
+ assert isinstance(arguments, str), f"expected str, found {type(arguments)}"
672
+ yield TOOL_CALL_FUNCTION_ARGUMENTS_JSON, arguments
673
+
674
+
675
+ @stop_on_exception
676
+ def _prompt_template(run: Run) -> Iterator[Tuple[str, AttributeValue]]:
677
+ yield from _parse_prompt_template(run.inputs, run.serialized)
678
+
679
+
680
+ @stop_on_exception
681
+ def _parse_prompt_template(
682
+ inputs: Mapping[str, str],
683
+ serialized: Optional[Mapping[str, Any]],
684
+ ) -> Iterator[Tuple[str, AttributeValue]]:
685
+ if (
686
+ not serialized
687
+ or not isinstance(serialized, Mapping)
688
+ or not (kwargs := serialized.get("kwargs"))
689
+ or not isinstance(kwargs, Mapping)
690
+ ):
691
+ return
692
+ if _get_cls_name(prompt := kwargs.get("prompt")).endswith("PromptTemplate"):
693
+ yield from _parse_prompt_template(inputs, prompt)
694
+ elif _get_cls_name(serialized).endswith("ChatPromptTemplate"):
695
+ messages = kwargs.get("messages")
696
+ assert isinstance(messages, Sequence), f"expected list, found {type(messages)}"
697
+ # FIXME: Multiple templates are possible (and the templated messages can also be
698
+ # interleaved with user massages), but we only have room for one template.
699
+ message = messages[0]
700
+ assert isinstance(message, Mapping), f"expected dict, found {type(message)}"
701
+ if partial_variables := kwargs.get("partial_variables"):
702
+ assert isinstance(
703
+ partial_variables, Mapping
704
+ ), f"expected dict, found {type(partial_variables)}"
705
+ inputs = {**partial_variables, **inputs}
706
+ yield from _parse_prompt_template(inputs, message)
707
+ elif _get_cls_name(serialized).endswith("PromptTemplate") and isinstance(
708
+ (template := kwargs.get("template")), str
709
+ ):
710
+ yield LLM_PROMPT_TEMPLATE, template
711
+ if input_variables := kwargs.get("input_variables"):
712
+ assert isinstance(
713
+ input_variables, list
714
+ ), f"expected list, found {type(input_variables)}"
715
+ template_variables = {}
716
+ for variable in input_variables:
717
+ if (value := inputs.get(variable)) is not None:
718
+ template_variables[variable] = value
719
+ if template_variables:
720
+ yield LLM_PROMPT_TEMPLATE_VARIABLES, safe_json_dumps(template_variables)
721
+
722
+
723
+ @stop_on_exception
724
+ def _invocation_parameters(run: Run) -> Iterator[Tuple[str, str]]:
725
+ """Yields invocation parameters if present."""
726
+ if run.run_type.lower() != "llm":
727
+ return
728
+ if not (extra := run.extra):
729
+ return
730
+ assert hasattr(extra, "get"), f"expected Mapping, found {type(extra)}"
731
+ if invocation_parameters := extra.get("invocation_params"):
732
+ assert isinstance(
733
+ invocation_parameters, Mapping
734
+ ), f"expected Mapping, found {type(invocation_parameters)}"
735
+ yield LLM_INVOCATION_PARAMETERS, safe_json_dumps(invocation_parameters)
736
+
737
+
738
+ @stop_on_exception
739
+ def _model_name(extra: Optional[Mapping[str, Any]]) -> Iterator[Tuple[str, str]]:
740
+ """Yields model name if present."""
741
+ if not extra:
742
+ return
743
+ assert hasattr(extra, "get"), f"expected Mapping, found {type(extra)}"
744
+ if not (invocation_params := extra.get("invocation_params")):
745
+ return
746
+ for key in ["model_name", "model"]:
747
+ if name := invocation_params.get(key):
748
+ yield LLM_MODEL_NAME, name
749
+ return
750
+
751
+
752
+ @stop_on_exception
753
+ def _token_counts(outputs: Optional[Mapping[str, Any]]) -> Iterator[Tuple[str, int]]:
754
+ """Yields token count information if present."""
755
+ if not (
756
+ token_usage := (
757
+ _parse_token_usage_for_non_streaming_outputs(outputs)
758
+ or _parse_token_usage_for_streaming_outputs(outputs)
759
+ )
760
+ ):
761
+ return
762
+ for attribute_name, keys in [
763
+ (
764
+ LLM_TOKEN_COUNT_PROMPT,
765
+ (
766
+ "prompt_tokens",
767
+ "input_tokens", # Anthropic-specific key
768
+ ),
769
+ ),
770
+ (
771
+ LLM_TOKEN_COUNT_COMPLETION,
772
+ (
773
+ "completion_tokens",
774
+ "output_tokens", # Anthropic-specific key
775
+ ),
776
+ ),
777
+ (LLM_TOKEN_COUNT_TOTAL, ("total_tokens",)),
778
+ ]:
779
+ if (token_count := _get_first_value(token_usage, keys)) is not None:
780
+ yield attribute_name, token_count
781
+
782
+
783
+ def _parse_token_usage_for_non_streaming_outputs(
784
+ outputs: Optional[Mapping[str, Any]],
785
+ ) -> Any:
786
+ """
787
+ Parses output to get token usage information for non-streaming LLMs, i.e.,
788
+ when `stream_usage` is set to false.
789
+ """
790
+ if (
791
+ outputs
792
+ and hasattr(outputs, "get")
793
+ and (llm_output := outputs.get("llm_output"))
794
+ and hasattr(llm_output, "get")
795
+ and (
796
+ token_usage := _get_first_value(
797
+ llm_output,
798
+ (
799
+ "token_usage",
800
+ "usage", # Anthropic-specific key
801
+ ),
802
+ )
803
+ )
804
+ ):
805
+ return token_usage
806
+ return None
807
+
808
+
809
+ def _parse_token_usage_for_streaming_outputs(
810
+ outputs: Optional[Mapping[str, Any]],
811
+ ) -> Any:
812
+ """
813
+ Parses output to get token usage information for streaming LLMs, i.e., when
814
+ `stream_usage` is set to true.
815
+ """
816
+ if (
817
+ outputs
818
+ and hasattr(outputs, "get")
819
+ and (generations := outputs.get("generations"))
820
+ and hasattr(generations, "__getitem__")
821
+ and generations[0]
822
+ and hasattr(generations[0], "__getitem__")
823
+ and (generation := generations[0][0])
824
+ and hasattr(generation, "get")
825
+ and (message := generation.get("message"))
826
+ and hasattr(message, "get")
827
+ and (kwargs := message.get("kwargs"))
828
+ and hasattr(kwargs, "get")
829
+ and (token_usage := kwargs.get("usage_metadata"))
830
+ ):
831
+ return token_usage
832
+ return None
833
+
834
+
835
+ @stop_on_exception
836
+ def _function_calls(outputs: Optional[Mapping[str, Any]]) -> Iterator[Tuple[str, str]]:
837
+ """Yields function call information if present."""
838
+ if not outputs:
839
+ return
840
+ assert hasattr(outputs, "get"), f"expected Mapping, found {type(outputs)}"
841
+ try:
842
+ function_call_data = deepcopy(
843
+ outputs["generations"][0][0]["message"]["kwargs"]["additional_kwargs"][
844
+ "function_call"
845
+ ]
846
+ )
847
+ function_call_data["arguments"] = json.loads(function_call_data["arguments"])
848
+ yield LLM_FUNCTION_CALL, safe_json_dumps(function_call_data)
849
+ except Exception:
850
+ pass
851
+
852
+
853
+ @stop_on_exception
854
+ def _tools(run: Run) -> Iterator[Tuple[str, str]]:
855
+ """Yields tool attributes if present."""
856
+ if run.run_type.lower() != "tool":
857
+ return
858
+ if not (serialized := run.serialized):
859
+ return
860
+ assert hasattr(serialized, "get"), f"expected Mapping, found {type(serialized)}"
861
+ if name := serialized.get("name"):
862
+ yield TOOL_NAME, name
863
+ if description := serialized.get("description"):
864
+ yield TOOL_DESCRIPTION, description
865
+
866
+
867
+ @stop_on_exception
868
+ def _retrieval_documents(run: Run) -> Iterator[Tuple[str, List[Mapping[str, Any]]]]:
869
+ if run.run_type.lower() != "retriever":
870
+ return
871
+ if not (outputs := run.outputs):
872
+ return
873
+ assert hasattr(outputs, "get"), f"expected Mapping, found {type(outputs)}"
874
+ documents = outputs.get("documents")
875
+ assert isinstance(
876
+ documents, Iterable
877
+ ), f"expected Iterable, found {type(documents)}"
878
+ yield RETRIEVAL_DOCUMENTS, [dict(_as_document(document)) for document in documents]
879
+
880
+
881
+ @stop_on_exception
882
+ def _metadata(run: Run) -> Iterator[Tuple[str, str]]:
883
+ """
884
+ Takes the LangChain chain metadata and adds it to the trace
885
+ """
886
+ if not run.extra or not (metadata := run.extra.get("metadata")):
887
+ return
888
+ assert isinstance(metadata, Mapping), f"expected Mapping, found {type(metadata)}"
889
+ if session_id := (
890
+ metadata.get(LANGCHAIN_SESSION_ID)
891
+ or metadata.get(LANGCHAIN_CONVERSATION_ID)
892
+ or metadata.get(LANGCHAIN_THREAD_ID)
893
+ ):
894
+ yield SESSION_ID, session_id
895
+ yield METADATA, safe_json_dumps(metadata)
896
+
897
+
898
+ @stop_on_exception
899
+ def _as_document(document: Any) -> Iterator[Tuple[str, Any]]:
900
+ if page_content := getattr(document, "page_content", None):
901
+ assert isinstance(
902
+ page_content, str
903
+ ), f"expected str, found {type(page_content)}"
904
+ yield DOCUMENT_CONTENT, page_content
905
+ if metadata := getattr(document, "metadata", None):
906
+ assert isinstance(
907
+ metadata, Mapping
908
+ ), f"expected Mapping, found {type(metadata)}"
909
+ yield DOCUMENT_METADATA, safe_json_dumps(metadata)
910
+
911
+
912
+ def _as_utc_nano(dt: datetime) -> int:
913
+ return int(dt.astimezone(timezone.utc).timestamp() * 1_000_000_000)
914
+
915
+
916
+ def _get_cls_name(serialized: Optional[Mapping[str, Any]]) -> str:
917
+ """
918
+ For a `Serializable` object, its class name, i.e. `cls.__name__`, is the last element of
919
+ its `lc_id`. See https://github.com/langchain-ai/langchain/blob/9e4a0e76f6aa9796ad7baa7f623ba98274676c6f/libs/core/langchain_core/load/serializable.py#L159
920
+
921
+ For example, for the class `langchain.llms.openai.OpenAI`, the id is
922
+ ["langchain", "llms", "openai", "OpenAI"], and `cls.__name__` is "OpenAI".
923
+ """ # noqa E501
924
+ if serialized is None or not hasattr(serialized, "get"):
925
+ return ""
926
+ if (
927
+ (id_ := serialized.get("id"))
928
+ and isinstance(id_, list)
929
+ and isinstance(id_[-1], str)
930
+ ):
931
+ return id_[-1]
932
+ return ""
933
+
934
+
935
+ KeyType = TypeVar("KeyType")
936
+ ValueType = TypeVar("ValueType")
937
+
938
+
939
+ def _get_first_value(
940
+ mapping: Mapping[KeyType, ValueType], keys: Iterable[KeyType]
941
+ ) -> Optional[ValueType]:
942
+ """
943
+ Returns the first non-null value corresponding to an input key, or None if
944
+ no non-null value is found.
945
+ """
946
+ if not hasattr(mapping, "get"):
947
+ return None
948
+ return next(
949
+ (value for key in keys if (value := mapping.get(key)) is not None),
950
+ None,
951
+ )
952
+
953
+
954
+ def _get_attributes_from_message_content(
955
+ content: Mapping[str, Any],
956
+ ) -> Iterator[Tuple[str, AttributeValue]]:
957
+ content = dict(content)
958
+ type_ = content.pop("type")
959
+ if type_ == "text":
960
+ yield f"{MESSAGE_CONTENT_TYPE}", "text"
961
+ if text := content.pop("text"):
962
+ yield f"{MESSAGE_CONTENT_TEXT}", text
963
+ elif type_ == "image_url":
964
+ yield f"{MESSAGE_CONTENT_TYPE}", "image"
965
+ if image := content.pop("image_url"):
966
+ for key, value in _get_attributes_from_image(image):
967
+ yield f"{MESSAGE_CONTENT_IMAGE}.{key}", value
968
+
969
+
970
+ def _get_attributes_from_image(
971
+ image: Mapping[str, Any],
972
+ ) -> Iterator[Tuple[str, AttributeValue]]:
973
+ image = dict(image)
974
+ if url := image.pop("url"):
975
+ yield f"{IMAGE_URL}", url
976
+
977
+
978
+ def _filter_images(messages: Any) -> Tuple[Any, List[str]]:
979
+ filtered_data = []
980
+ images = []
981
+ eval_input = []
982
+ query = ""
983
+
984
+ if not messages or not isinstance(messages, list):
985
+ return messages, [], "", ""
986
+
987
+ for message_group in messages:
988
+ if not isinstance(message_group, list):
989
+ continue
990
+
991
+ for message in message_group:
992
+ if isinstance(message, dict):
993
+ kwargs = message.get("kwargs", {})
994
+ if not isinstance(kwargs, dict):
995
+ continue
996
+
997
+ content = kwargs.get("content")
998
+ if isinstance(content, str):
999
+ filtered_data.append({"type": "text", "text": content})
1000
+ eval_input.append(content)
1001
+ elif isinstance(content, list):
1002
+ for obj in content:
1003
+ if not isinstance(obj, dict):
1004
+ continue
1005
+
1006
+ if obj.get("type") == "text":
1007
+ filtered_data.append(obj)
1008
+ text = obj.get("text")
1009
+ if text:
1010
+ eval_input.append(str(text))
1011
+ elif obj.get("type") == "image_url":
1012
+ image_url = obj.get("image_url", {}).get("url")
1013
+ if image_url:
1014
+ images.append(image_url)
1015
+ filtered_data.append(
1016
+ {"type": "text", "text": f"[Image: {image_url}]"}
1017
+ )
1018
+
1019
+ if eval_input and len(eval_input) > 0:
1020
+ query = eval_input[0]
1021
+ eval_input = " | ".join(map(str, eval_input))
1022
+ return filtered_data, images, eval_input, query
1023
+
1024
+
1025
+ LANGCHAIN_SESSION_ID = "session_id"
1026
+ LANGCHAIN_CONVERSATION_ID = "conversation_id"
1027
+ LANGCHAIN_THREAD_ID = "thread_id"
1028
+
1029
+ DOCUMENT_CONTENT = DocumentAttributes.DOCUMENT_CONTENT
1030
+ DOCUMENT_ID = DocumentAttributes.DOCUMENT_ID
1031
+ DOCUMENT_METADATA = DocumentAttributes.DOCUMENT_METADATA
1032
+ DOCUMENT_SCORE = DocumentAttributes.DOCUMENT_SCORE
1033
+ EMBEDDING_EMBEDDINGS = SpanAttributes.EMBEDDING_EMBEDDINGS
1034
+ EMBEDDING_MODEL_NAME = SpanAttributes.EMBEDDING_MODEL_NAME
1035
+ EMBEDDING_TEXT = EmbeddingAttributes.EMBEDDING_TEXT
1036
+ EMBEDDING_VECTOR = EmbeddingAttributes.EMBEDDING_VECTOR
1037
+ IMAGE_URL = ImageAttributes.IMAGE_URL
1038
+ INPUT_MIME_TYPE = SpanAttributes.INPUT_MIME_TYPE
1039
+ INPUT_VALUE = SpanAttributes.INPUT_VALUE
1040
+ INPUT_IMAGES = SpanAttributes.INPUT_IMAGES
1041
+ EVAL_INPUT = SpanAttributes.EVAL_INPUT
1042
+ RAW_INPUT = SpanAttributes.RAW_INPUT
1043
+ RAW_OUTPUT = SpanAttributes.RAW_OUTPUT
1044
+ QUERY = SpanAttributes.QUERY
1045
+ LLM_FUNCTION_CALL = SpanAttributes.LLM_FUNCTION_CALL
1046
+ LLM_INPUT_MESSAGES = SpanAttributes.LLM_INPUT_MESSAGES
1047
+ LLM_INVOCATION_PARAMETERS = SpanAttributes.LLM_INVOCATION_PARAMETERS
1048
+ LLM_MODEL_NAME = SpanAttributes.LLM_MODEL_NAME
1049
+ LLM_OUTPUT_MESSAGES = SpanAttributes.LLM_OUTPUT_MESSAGES
1050
+ LLM_PROMPTS = SpanAttributes.LLM_PROMPTS
1051
+ LLM_PROMPT_TEMPLATE = SpanAttributes.LLM_PROMPT_TEMPLATE
1052
+ LLM_PROMPT_TEMPLATE_VARIABLES = SpanAttributes.LLM_PROMPT_TEMPLATE_VARIABLES
1053
+ LLM_TOKEN_COUNT_COMPLETION = SpanAttributes.LLM_TOKEN_COUNT_COMPLETION
1054
+ LLM_TOKEN_COUNT_PROMPT = SpanAttributes.LLM_TOKEN_COUNT_PROMPT
1055
+ LLM_TOKEN_COUNT_TOTAL = SpanAttributes.LLM_TOKEN_COUNT_TOTAL
1056
+ MESSAGE_CONTENT = MessageAttributes.MESSAGE_CONTENT
1057
+ MESSAGE_CONTENTS = MessageAttributes.MESSAGE_CONTENTS
1058
+ MESSAGE_CONTENT_IMAGE = MessageContentAttributes.MESSAGE_CONTENT_IMAGE
1059
+ MESSAGE_CONTENT_TEXT = MessageContentAttributes.MESSAGE_CONTENT_TEXT
1060
+ MESSAGE_CONTENT_TYPE = MessageContentAttributes.MESSAGE_CONTENT_TYPE
1061
+ MESSAGE_FUNCTION_CALL_ARGUMENTS_JSON = (
1062
+ MessageAttributes.MESSAGE_FUNCTION_CALL_ARGUMENTS_JSON
1063
+ )
1064
+ MESSAGE_FUNCTION_CALL_NAME = MessageAttributes.MESSAGE_FUNCTION_CALL_NAME
1065
+ MESSAGE_NAME = MessageAttributes.MESSAGE_NAME
1066
+ MESSAGE_ROLE = MessageAttributes.MESSAGE_ROLE
1067
+ MESSAGE_TOOL_CALLS = MessageAttributes.MESSAGE_TOOL_CALLS
1068
+ METADATA = SpanAttributes.METADATA
1069
+ FI_SPAN_KIND = SpanAttributes.FI_SPAN_KIND
1070
+ OUTPUT_MIME_TYPE = SpanAttributes.OUTPUT_MIME_TYPE
1071
+ OUTPUT_VALUE = SpanAttributes.OUTPUT_VALUE
1072
+ RERANKER_INPUT_DOCUMENTS = RerankerAttributes.RERANKER_INPUT_DOCUMENTS
1073
+ RERANKER_MODEL_NAME = RerankerAttributes.RERANKER_MODEL_NAME
1074
+ RERANKER_OUTPUT_DOCUMENTS = RerankerAttributes.RERANKER_OUTPUT_DOCUMENTS
1075
+ RERANKER_QUERY = RerankerAttributes.RERANKER_QUERY
1076
+ RERANKER_TOP_K = RerankerAttributes.RERANKER_TOP_K
1077
+ RETRIEVAL_DOCUMENTS = SpanAttributes.RETRIEVAL_DOCUMENTS
1078
+ SESSION_ID = SpanAttributes.SESSION_ID
1079
+ TOOL_CALL_FUNCTION_ARGUMENTS_JSON = ToolCallAttributes.TOOL_CALL_FUNCTION_ARGUMENTS_JSON
1080
+ TOOL_CALL_FUNCTION_NAME = ToolCallAttributes.TOOL_CALL_FUNCTION_NAME
1081
+ TOOL_DESCRIPTION = SpanAttributes.TOOL_DESCRIPTION
1082
+ TOOL_NAME = SpanAttributes.TOOL_NAME
1083
+ TOOL_PARAMETERS = SpanAttributes.TOOL_PARAMETERS
@@ -0,0 +1,2 @@
1
+ _instruments = ("langchain_core >= 0.1.0",)
2
+ _supports_metrics = False
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"