fiddler-langgraph 0.1.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,795 @@
1
+ """Callback handler for LangGraph instrumentation."""
2
+
3
+ import json
4
+ import logging
5
+ from collections.abc import Sequence
6
+ from functools import cached_property
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from langchain_core.callbacks import BaseCallbackHandler
11
+ from langchain_core.documents import Document
12
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
13
+ from langchain_core.outputs import ChatGeneration, LLMResult
14
+ from opentelemetry import trace
15
+ from opentelemetry.context.context import Context
16
+
17
+ from fiddler_langgraph.core.attributes import (
18
+ _CONVERSATION_ID,
19
+ FIDDLER_METADATA_KEY,
20
+ FIDDLER_USER_SPAN_ATTRIBUTE_TEMPLATE,
21
+ FiddlerSpanAttributes,
22
+ SpanType,
23
+ )
24
+ from fiddler_langgraph.tracing.util import _LanggraphJSONEncoder
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _get_agent_name(metadata: dict[str, Any]) -> str:
30
+ """Get the agent name from the kwargs."""
31
+ agent_name = ''
32
+ checkpoint_ns = metadata.get('langgraph_checkpoint_ns', '')
33
+ if checkpoint_ns:
34
+ path = checkpoint_ns.split(':')
35
+ agent_name = path[0]
36
+ return agent_name
37
+
38
+
39
+ def _set_agent_name(span: trace.Span, metadata: dict[str, Any]) -> None:
40
+ """Get the agent name from the kwargs."""
41
+ agent_name = _get_agent_name(metadata)
42
+ span.set_attribute(FiddlerSpanAttributes.AGENT_NAME, agent_name)
43
+ _set_agent_id(span, agent_name)
44
+
45
+
46
+ def _set_agent_id(span: trace.Span, agent_name: str) -> None:
47
+ """Set the agent ID on the span."""
48
+ trace_id = format(span.get_span_context().trace_id, '032x')
49
+ agent_id = str(trace_id) + ':' + agent_name
50
+ span.set_attribute(FiddlerSpanAttributes.AGENT_ID, agent_id)
51
+
52
+
53
+ def _stringify_message_content(message: BaseMessage) -> str:
54
+ """Stringify a message."""
55
+ if isinstance(message.content, str):
56
+ return message.content
57
+ return json.dumps(message.content, cls=_LanggraphJSONEncoder)
58
+
59
+
60
+ def _set_model_attributes(span: trace.Span, metadata: dict[str, Any] | None = None) -> None:
61
+ """Set model-related attributes on a span.
62
+
63
+ Extracts model name and provider from metadata.
64
+
65
+ Parameters
66
+ ----------
67
+ span : trace.Span
68
+ The OpenTelemetry span to set attributes on
69
+ metadata : dict[str, Any] | None
70
+ The metadata containing model information
71
+
72
+ """
73
+ if not metadata:
74
+ return
75
+
76
+ # Extract model information
77
+ ls_model_name = metadata.get('ls_model_name')
78
+ ls_provider = metadata.get('ls_provider')
79
+
80
+ # Set model name attribute
81
+ if ls_model_name and isinstance(ls_model_name, str) and ls_model_name.strip():
82
+ span.set_attribute(FiddlerSpanAttributes.LLM_REQUEST_MODEL, ls_model_name.strip())
83
+
84
+ # Set provider attribute
85
+ if ls_provider and isinstance(ls_provider, str) and ls_provider.strip():
86
+ span.set_attribute(FiddlerSpanAttributes.LLM_SYSTEM, ls_provider.strip())
87
+
88
+
89
+ def _set_token_usage_attributes(span: trace.Span, response: LLMResult) -> None:
90
+ """Set token usage attributes on a span from LLMResult.
91
+
92
+ Extracts token usage information from the LLM response
93
+
94
+ Parameters
95
+ ----------
96
+ span : trace.Span
97
+ The OpenTelemetry span to set attributes on
98
+ response : LLMResult
99
+ The LLM response containing token usage information
100
+
101
+ """
102
+ try:
103
+ if not response.generations or not response.generations[0]:
104
+ return
105
+
106
+ generation = response.generations[0][0]
107
+
108
+ if not (
109
+ isinstance(generation, ChatGeneration)
110
+ and hasattr(generation.message, 'usage_metadata')
111
+ and generation.message.usage_metadata
112
+ ):
113
+ return
114
+
115
+ usage_metadata = generation.message.usage_metadata
116
+
117
+ # Set input tokens
118
+ input_tokens = usage_metadata.get('input_tokens')
119
+ if input_tokens is not None and isinstance(input_tokens, int):
120
+ span.set_attribute(FiddlerSpanAttributes.LLM_TOKEN_COUNT_INPUT, input_tokens)
121
+
122
+ # Set output tokens
123
+ output_tokens = usage_metadata.get('output_tokens')
124
+ if output_tokens is not None and isinstance(output_tokens, int):
125
+ span.set_attribute(FiddlerSpanAttributes.LLM_TOKEN_COUNT_OUTPUT, output_tokens)
126
+
127
+ # Set total tokens
128
+ total_tokens = usage_metadata.get('total_tokens')
129
+ if total_tokens is not None and isinstance(total_tokens, int):
130
+ span.set_attribute(FiddlerSpanAttributes.LLM_TOKEN_COUNT_TOTAL, total_tokens)
131
+ else:
132
+ # Calculate total if not provided
133
+ if input_tokens and output_tokens:
134
+ calculated_total = input_tokens + output_tokens
135
+ span.set_attribute(FiddlerSpanAttributes.LLM_TOKEN_COUNT_TOTAL, calculated_total)
136
+
137
+ except Exception as e:
138
+ logger.warning('Failed to extract token usage: %s', e)
139
+
140
+
141
+ class _CallbackHandler(BaseCallbackHandler):
142
+ """A LangChain callback handler that creates OpenTelemetry spans for Fiddler.
143
+
144
+ This handler listens to events from LangGraph and creates corresponding
145
+ spans to trace the execution of chains, tools, and language models.
146
+ It is responsible for managing the lifecycle of these spans, including
147
+ their creation, activation, and completion.
148
+
149
+ Attributes:
150
+ _tracer (trace.Tracer): The OpenTelemetry tracer used to create spans.
151
+ _active_spans (dict[UUID, trace.Span]): A dictionary mapping run IDs
152
+ to active spans.
153
+ _root_span (trace.Span | None): The root span of the current trace.
154
+ session_id (str | None): The ID of the current conversation or session.
155
+ """
156
+
157
+ def __init__(self, tracer: trace.Tracer):
158
+ """Initializes the callback handler.
159
+
160
+ Args:
161
+ tracer: The OpenTelemetry tracer to use for creating and managing spans.
162
+ """
163
+ self._active_spans: dict[UUID, trace.Span] = {}
164
+ self._tracer = tracer
165
+ self._root_span: trace.Span | None = None
166
+ # our callback handler needs to have its own context
167
+ # so that the spans created by the callback handler are not affected by the global context
168
+ # if the global context is set to a different trace, the spans created by the callback handler
169
+ # will be part of a different trace
170
+ self._context = Context()
171
+
172
+ def _start_new_trace(self, trace_name: str) -> trace.Span:
173
+ """Start a new trace with the given name."""
174
+ span = self._tracer.start_span(
175
+ trace_name, kind=trace.SpanKind.CLIENT, context=self._context
176
+ )
177
+ span.set_attribute(FiddlerSpanAttributes.TYPE, SpanType.CHAIN)
178
+ # we don't know the agent name when the graph starts, so we set it to unknown
179
+ # we will update it when the second chain starts
180
+ span.set_attribute(FiddlerSpanAttributes.AGENT_NAME, 'unknown')
181
+ _set_agent_id(span, 'unknown')
182
+ self._set_session_id(span)
183
+ self._root_span = span
184
+
185
+ return span
186
+
187
+ def _add_span(self, span: trace.Span, run_id: UUID) -> None:
188
+ """Adds a span to the active spans dictionary."""
189
+ self._active_spans[run_id] = span
190
+
191
+ def _get_span(self, run_id: UUID | None) -> trace.Span | None:
192
+ """Retrieves a span from the active spans dictionary by its run ID."""
193
+ if run_id is None:
194
+ return None
195
+ return self._active_spans.get(run_id)
196
+
197
+ def _remove_span(self, run_id: UUID) -> None:
198
+ """Removes a span from the active spans dictionary."""
199
+ del self._active_spans[run_id]
200
+ if len(self._active_spans) == 0:
201
+ # reset the root span if no active spans are left
202
+ self._root_span = None
203
+
204
+ def _set_fiddler_attributes_from_metadata(
205
+ self, span: trace.Span, metadata: dict[str, Any] | None
206
+ ) -> None:
207
+ """Sets Fiddler-specific attributes on a span from metadata."""
208
+ if metadata is not None:
209
+ fiddler_attributes = metadata.get(FIDDLER_METADATA_KEY, {})
210
+ for key, value in fiddler_attributes.items():
211
+ fdl_key = FIDDLER_USER_SPAN_ATTRIBUTE_TEMPLATE.format(key=key)
212
+ span.set_attribute(fdl_key, value)
213
+
214
+ def _update_root_span_agent_name(self, agent_name: str) -> None:
215
+ """Updates the agent name on the root span.
216
+
217
+ The root span is created without an agent name, so this method is
218
+ used to update it once the agent name becomes available.
219
+
220
+ Args:
221
+ agent_name: The agent name to set on the root span.
222
+ """
223
+ if self._root_span and self._root_span.is_recording():
224
+ self._root_span.set_attribute(FiddlerSpanAttributes.AGENT_NAME, agent_name)
225
+ _set_agent_id(self._root_span, agent_name)
226
+
227
+ def _start_trace(self, trace_name: str, run_id: UUID) -> None:
228
+ """Starts a new trace and adds the root span to the active spans."""
229
+ span = self._start_new_trace(trace_name)
230
+ self._add_span(span, run_id)
231
+
232
+ @cached_property
233
+ def session_id(self) -> str:
234
+ """Get the session id from the metadata."""
235
+ return _CONVERSATION_ID.get()
236
+
237
+ def _set_session_id(self, span: trace.Span) -> None:
238
+ """Sets the session ID as an attribute on the given span."""
239
+ if self.session_id:
240
+ span.set_attribute(FiddlerSpanAttributes.CONVERSATION_ID, self.session_id)
241
+
242
+ def _create_child_span(self, parent_span: trace.Span, span_name: str) -> trace.Span:
243
+ """Get a child span."""
244
+ parent_context = trace.set_span_in_context(parent_span, self._context)
245
+ return self._tracer.start_span(span_name, context=parent_context)
246
+
247
+ def on_chain_start(
248
+ self,
249
+ serialized: dict[str, Any],
250
+ inputs: dict[str, Any],
251
+ *,
252
+ run_id: UUID,
253
+ parent_run_id: UUID | None = None,
254
+ tags: list[str] | None = None,
255
+ metadata: dict[str, Any] | None = None,
256
+ **kwargs: Any,
257
+ ) -> Any:
258
+ """Called when a chain starts.
259
+
260
+ This method creates a new span for the chain execution. If this is the
261
+ first event in a trace, it creates a root span. Otherwise, it creates
262
+ a child span of the currently active span.
263
+
264
+ Args:
265
+ serialized: The serialized representation of the chain.
266
+ inputs: The inputs to the chain.
267
+ run_id: The unique ID of the chain run.
268
+ parent_run_id: The ID of the parent run, if any.
269
+ tags: A list of tags for the chain.
270
+ metadata: A dictionary of metadata for the chain.
271
+ **kwargs: Additional keyword arguments.
272
+ """
273
+ if not self._root_span:
274
+ trace_name = kwargs.get('name', 'unknown')
275
+ self._start_trace(trace_name, run_id)
276
+ return
277
+ agent_name = _get_agent_name(metadata) if metadata is not None else 'unknown'
278
+ parent_span = self._get_span(parent_run_id)
279
+ if parent_span is None:
280
+ # if for some reason the parent span is not found, we can just return - don't generate faulty child spans
281
+ logger.warning(
282
+ 'on_chain_start no parent span for run_id %s , parent_run_id %s',
283
+ run_id,
284
+ parent_run_id,
285
+ )
286
+ return
287
+ child_span = self._create_child_span(parent_span, kwargs.get('name', 'unknown'))
288
+
289
+ child_span.set_attribute(FiddlerSpanAttributes.TYPE, SpanType.CHAIN)
290
+ child_span.set_attribute(FiddlerSpanAttributes.AGENT_NAME, agent_name)
291
+ _set_agent_id(child_span, agent_name)
292
+ self._update_root_span_agent_name(agent_name)
293
+
294
+ if metadata is not None:
295
+ self._set_fiddler_attributes_from_metadata(child_span, metadata)
296
+
297
+ self._set_session_id(child_span)
298
+ self._add_span(child_span, run_id)
299
+
300
+ def on_chain_end(
301
+ self,
302
+ outputs: dict[str, Any],
303
+ *,
304
+ run_id: UUID,
305
+ parent_run_id: UUID | None = None,
306
+ **kwargs: Any,
307
+ ) -> Any:
308
+ """Called when a chain ends.
309
+
310
+ This method finds the corresponding span for the chain run, sets its
311
+ status to OK, and ends the span.
312
+
313
+ Args:
314
+ outputs: The outputs of the chain.
315
+ run_id: The unique ID of the chain run.
316
+ parent_run_id: The ID of the parent run, if any.
317
+ **kwargs: Additional keyword arguments.
318
+ """
319
+ span = self._get_span(run_id)
320
+ if span:
321
+ span.set_status(trace.Status(trace.StatusCode.OK))
322
+ span.end()
323
+ self._remove_span(run_id)
324
+ else:
325
+ logger.warning('on_chain_end no active span: %s, %s', run_id, kwargs)
326
+
327
+ def on_chain_error(
328
+ self,
329
+ error: BaseException,
330
+ *,
331
+ run_id: UUID,
332
+ parent_run_id: UUID | None = None,
333
+ **kwargs: Any,
334
+ ) -> Any:
335
+ """Called when a chain encounters an error.
336
+
337
+ This method finds the corresponding span, records the exception, sets
338
+ the status to ERROR, and ends the span.
339
+
340
+ Args:
341
+ error: The exception that occurred.
342
+ run_id: The unique ID of the chain run.
343
+ parent_run_id: The ID of the parent run, if any.
344
+ **kwargs: Additional keyword arguments.
345
+ """
346
+ span = self._get_span(run_id)
347
+ if span:
348
+ span.record_exception(error)
349
+ # Use repr() for more complete error information, fallback to str() if repr() is empty
350
+ error_message = repr(error) if repr(error) else str(error)
351
+ span.set_status(trace.Status(trace.StatusCode.ERROR, error_message))
352
+ span.end()
353
+ self._remove_span(run_id)
354
+ else:
355
+ logger.warning('on_chain_error no active span: %s, %s', run_id, kwargs)
356
+
357
+ def on_tool_start(
358
+ self,
359
+ serialized: dict[str, Any],
360
+ input_str: str,
361
+ *,
362
+ run_id: UUID,
363
+ parent_run_id: UUID | None = None,
364
+ tags: list[str] | None = None,
365
+ metadata: dict[str, Any] | None = None,
366
+ inputs: dict[str, Any] | None = None,
367
+ **kwargs: Any,
368
+ ) -> Any:
369
+ """Called when a tool starts.
370
+
371
+ This method creates a new span for the tool execution as a child of
372
+ the currently active span.
373
+
374
+ Args:
375
+ serialized: The serialized representation of the tool.
376
+ input_str: The input to the tool.
377
+ run_id: The unique ID of the tool run.
378
+ parent_run_id: The ID of the parent run, if any.
379
+ tags: A list of tags for the tool.
380
+ metadata: A dictionary of metadata for the tool.
381
+ inputs: The inputs to the tool.
382
+ **kwargs: Additional keyword arguments.
383
+ """
384
+ parent_span = self._get_span(parent_run_id)
385
+ if parent_span is None:
386
+ # if for some reason the parent span is not found, we can just return - don't generate faulty child spans
387
+ logger.warning(
388
+ 'on_tool_start no parent span for run_id %s , parent_run_id %s',
389
+ run_id,
390
+ parent_run_id,
391
+ )
392
+ return
393
+ child_span = self._create_child_span(parent_span, serialized.get('name', 'unknown'))
394
+ span_input = json.dumps(inputs, cls=_LanggraphJSONEncoder) if inputs else input_str
395
+ child_span.set_attribute(FiddlerSpanAttributes.TYPE, SpanType.TOOL)
396
+ child_span.set_attribute(FiddlerSpanAttributes.TOOL_NAME, serialized.get('name', 'unknown'))
397
+ child_span.set_attribute(FiddlerSpanAttributes.TOOL_INPUT, span_input)
398
+ if metadata is not None:
399
+ _set_agent_name(child_span, metadata)
400
+ self._set_fiddler_attributes_from_metadata(child_span, metadata)
401
+
402
+ self._set_session_id(child_span)
403
+ self._add_span(child_span, run_id)
404
+
405
+ def on_tool_end(
406
+ self,
407
+ output: Any,
408
+ *,
409
+ run_id: UUID,
410
+ parent_run_id: UUID | None = None,
411
+ **kwargs: Any,
412
+ ) -> Any:
413
+ """Called when a tool ends.
414
+
415
+ This method finds the corresponding span, sets its status to OK, and
416
+ ends the span.
417
+
418
+ Args:
419
+ output: The output of the tool.
420
+ run_id: The unique ID of the tool run.
421
+ parent_run_id: The ID of the parent run, if any.
422
+ **kwargs: Additional keyword arguments.
423
+ """
424
+ span = self._get_span(run_id)
425
+ if span:
426
+ # limit the output to 100 characters for now - add formal limits later
427
+ span.set_attribute(
428
+ FiddlerSpanAttributes.TOOL_OUTPUT,
429
+ json.dumps(output, cls=_LanggraphJSONEncoder),
430
+ )
431
+ span.set_status(trace.Status(trace.StatusCode.OK))
432
+ span.end()
433
+ self._remove_span(run_id)
434
+ else:
435
+ logger.warning('on_tool_end no active span: %s, %s', run_id, kwargs)
436
+
437
+ def on_tool_error(
438
+ self,
439
+ error: BaseException,
440
+ *,
441
+ run_id: UUID,
442
+ parent_run_id: UUID | None = None,
443
+ **kwargs: Any,
444
+ ) -> Any:
445
+ """Called when a tool encounters an error.
446
+
447
+ This method finds the corresponding span, records the exception, sets
448
+ the status to ERROR, and ends the span.
449
+
450
+ Args:
451
+ error: The exception that occurred.
452
+ run_id: The unique ID of the tool run.
453
+ parent_run_id: The ID of the parent run, if any.
454
+ **kwargs: Additional keyword arguments.
455
+ """
456
+ span = self._get_span(run_id)
457
+ if span:
458
+ span.record_exception(error)
459
+ # Use repr() for more complete error information, fallback to str() if repr() is empty
460
+ error_message = repr(error) if repr(error) else str(error)
461
+ span.set_status(trace.Status(trace.StatusCode.ERROR, error_message))
462
+ span.end()
463
+ self._remove_span(run_id)
464
+ else:
465
+ logger.warning('on_tool_error no active span: %s, %s', run_id, kwargs)
466
+
467
+ def on_retriever_start(
468
+ self,
469
+ serialized: dict[str, Any],
470
+ query: str,
471
+ *,
472
+ run_id: UUID,
473
+ parent_run_id: UUID | None = None,
474
+ tags: list[str] | None = None,
475
+ metadata: dict[str, Any] | None = None,
476
+ **kwargs: Any,
477
+ ) -> Any:
478
+ """Called when a retriever starts.
479
+
480
+ This method creates a new span for the retriever execution.
481
+
482
+ Args:
483
+ serialized: The serialized representation of the retriever.
484
+ query: The query sent to the retriever.
485
+ run_id: The unique ID of the retriever run.
486
+ parent_run_id: The ID of the parent run, if any.
487
+ tags: A list of tags for the retriever.
488
+ metadata: A dictionary of metadata for the retriever.
489
+ **kwargs: Additional keyword arguments.
490
+ """
491
+ parent_span = self._get_span(parent_run_id)
492
+ if parent_span is None:
493
+ # if for some reason the parent span is not found, we can just return - don't generate faulty child spans
494
+ logger.warning(
495
+ 'on_retriever_start no parent span for run_id %s , parent_run_id %s',
496
+ run_id,
497
+ parent_run_id,
498
+ )
499
+ return
500
+ child_span = self._create_child_span(parent_span, kwargs.get('name', 'unknown'))
501
+
502
+ child_span.set_attribute(FiddlerSpanAttributes.TYPE, SpanType.TOOL)
503
+ child_span.set_attribute(FiddlerSpanAttributes.TOOL_INPUT, query)
504
+
505
+ if metadata is not None:
506
+ _set_agent_name(child_span, metadata)
507
+ self._set_fiddler_attributes_from_metadata(child_span, metadata)
508
+
509
+ # semantic convention attributes
510
+ child_span.set_attribute(
511
+ FiddlerSpanAttributes.TYPE, SpanType.TOOL
512
+ ) # document retrieval is a tool
513
+ child_span.set_attribute(FiddlerSpanAttributes.TOOL_NAME, kwargs.get('name', 'unknown'))
514
+ child_span.set_attribute(FiddlerSpanAttributes.TOOL_INPUT, str(query))
515
+ self._set_session_id(child_span)
516
+ self._add_span(child_span, run_id)
517
+
518
+ def on_retriever_error(
519
+ self,
520
+ error: BaseException,
521
+ *,
522
+ run_id: UUID,
523
+ parent_run_id: UUID | None = None,
524
+ **kwargs: Any,
525
+ ) -> Any:
526
+ """Called when a retriever encounters an error.
527
+
528
+ This method finds the corresponding span, records the exception, sets
529
+ the status to ERROR, and ends the span.
530
+
531
+ Args:
532
+ error: The exception that occurred.
533
+ run_id: The unique ID of the retriever run.
534
+ parent_run_id: The ID of the parent run, if any.
535
+ **kwargs: Additional keyword arguments.
536
+ """
537
+ span = self._get_span(run_id)
538
+ if span:
539
+ span.record_exception(error)
540
+ # Use repr() for more complete error information, fallback to str() if repr() is empty
541
+ error_message = repr(error) if repr(error) else str(error)
542
+ span.set_status(trace.Status(trace.StatusCode.ERROR, error_message))
543
+ span.end()
544
+ self._remove_span(run_id)
545
+ else:
546
+ logger.warning('on_retriever_error no active span: %s, %s', run_id, kwargs)
547
+
548
+ def on_retriever_end(
549
+ self,
550
+ documents: Sequence[Document],
551
+ *,
552
+ run_id: UUID,
553
+ parent_run_id: UUID | None = None,
554
+ **kwargs: Any,
555
+ ) -> Any:
556
+ """Called when a retriever ends.
557
+
558
+ This method finds the corresponding span, records the retrieved
559
+ documents as an event, sets the status to OK, and ends the span.
560
+
561
+ Args:
562
+ documents: The documents retrieved by the retriever.
563
+ run_id: The unique ID of the retriever run.
564
+ parent_run_id: The ID of the parent run, if any.
565
+ **kwargs: Additional keyword arguments.
566
+ """
567
+ span = self._get_span(run_id)
568
+ if span:
569
+ span.set_status(trace.Status(trace.StatusCode.OK))
570
+ span.set_attribute(
571
+ FiddlerSpanAttributes.TOOL_OUTPUT,
572
+ json.dumps(documents, cls=_LanggraphJSONEncoder),
573
+ )
574
+
575
+ span.end()
576
+ self._remove_span(run_id)
577
+ else:
578
+ logger.warning('on_retriever_end no active span: %s, %s', run_id, kwargs)
579
+
580
+ def on_chat_model_start(
581
+ self,
582
+ serialized: dict[str, Any],
583
+ messages: list[list[BaseMessage]],
584
+ *,
585
+ run_id: UUID,
586
+ parent_run_id: UUID | None = None,
587
+ tags: list[str] | None = None,
588
+ metadata: dict[str, Any] | None = None,
589
+ **kwargs: Any,
590
+ ) -> Any:
591
+ """Called when a chat model starts.
592
+
593
+ This method creates a new span for the chat model execution and records
594
+ the input messages as events.
595
+
596
+ Args:
597
+ serialized: The serialized representation of the chat model.
598
+ messages: The messages sent to the chat model.
599
+ run_id: The unique ID of the chat model run.
600
+ parent_run_id: The ID of the parent run, if any.
601
+ tags: A list of tags for the chat model.
602
+ metadata: A dictionary of metadata for the chat model.
603
+ **kwargs: Additional keyword arguments.
604
+ """
605
+ parent_span = self._get_span(parent_run_id)
606
+ if parent_span is None:
607
+ # if for some reason the parent span is not found, we can just return - don't generate faulty child spans
608
+ logger.warning(
609
+ 'on_llm_start no parent span for run_id %s , parent_run_id %s',
610
+ run_id,
611
+ parent_run_id,
612
+ )
613
+ return
614
+ parent_context = trace.set_span_in_context(parent_span, self._context)
615
+ child_span = self._tracer.start_span(
616
+ serialized.get('name', 'unknown'), context=parent_context
617
+ )
618
+
619
+ # chat models are a special case of LLMs with Structure Inputs (messages)
620
+ # the ordering of messages is preserved over the lifecycle of an agent's invocation
621
+ # we are ignoring AIMessage, ToolMessage, FunctionMessage & ChatMessage
622
+ # see https://python.langchain.com/api_reference/core/messages.html#module-langchain_core.messages
623
+ system_message = []
624
+ user_message = []
625
+ if messages and messages[0]:
626
+ system_message = [m for m in messages[0] if isinstance(m, SystemMessage)]
627
+ user_message = [m for m in messages[0] if isinstance(m, HumanMessage)]
628
+
629
+ # breakpoint()
630
+ if metadata is not None:
631
+ _set_agent_name(child_span, metadata)
632
+
633
+ self._set_fiddler_attributes_from_metadata(child_span, metadata)
634
+
635
+ child_span.set_attribute(FiddlerSpanAttributes.TYPE, SpanType.LLM)
636
+
637
+ # Set model attributes
638
+ _set_model_attributes(child_span, metadata)
639
+
640
+ # We are only taking the 1st system message and 1st user message
641
+ # as we are not supporting multiple system messages or multiple user messages
642
+ # To support multiple system messages, we would need to add a new attribute with indexing
643
+ # or use event attributes
644
+ system_content = _stringify_message_content(system_message[-1]) if system_message else ''
645
+ user_content = _stringify_message_content(user_message[-1]) if user_message else ''
646
+ child_span.set_attribute(
647
+ FiddlerSpanAttributes.LLM_INPUT_SYSTEM,
648
+ system_content,
649
+ )
650
+ child_span.set_attribute(
651
+ FiddlerSpanAttributes.LLM_INPUT_USER,
652
+ user_content,
653
+ )
654
+ self._set_session_id(child_span)
655
+ self._add_span(child_span, run_id)
656
+
657
+ def on_llm_start(
658
+ self,
659
+ serialized: dict[str, Any],
660
+ prompts: list[str],
661
+ *,
662
+ run_id: UUID,
663
+ parent_run_id: UUID | None = None,
664
+ tags: list[str] | None = None,
665
+ metadata: dict[str, Any] | None = None,
666
+ **kwargs: Any,
667
+ ) -> Any:
668
+ """Called when a language model starts.
669
+
670
+ This method creates a new span for the language model execution and
671
+ records the prompts as attributes.
672
+
673
+ Args:
674
+ serialized: The serialized representation of the language model.
675
+ prompts: The prompts sent to the language model.
676
+ run_id: The unique ID of the language model run.
677
+ parent_run_id: The ID of the parent run, if any.
678
+ tags: A list of tags for the language model.
679
+ metadata: A dictionary of metadata for the language model.
680
+ **kwargs: Additional keyword arguments.
681
+ """
682
+ parent_span = self._get_span(parent_run_id)
683
+ if parent_span is None:
684
+ # if for some reason the parent span is not found, we can just return - don't generate faulty child spans
685
+ logger.warning(
686
+ 'on_llm_start no parent span for run_id %s , parent_run_id %s',
687
+ run_id,
688
+ parent_run_id,
689
+ )
690
+ return
691
+ child_span = self._create_child_span(parent_span, serialized.get('name', 'unknown'))
692
+
693
+ child_span.set_attribute(FiddlerSpanAttributes.TYPE, SpanType.LLM)
694
+
695
+ if metadata is not None:
696
+ _set_agent_name(child_span, metadata)
697
+ self._set_fiddler_attributes_from_metadata(child_span, metadata)
698
+
699
+ # Set model attributes
700
+ _set_model_attributes(child_span, metadata)
701
+
702
+ # LLM model is more generic than a chat model, it only has a list on prompts
703
+ # we are using the first prompt as both the system message and the user message
704
+ # to capture all the prompts, we would need to add a new attribute with indexing
705
+ # or use event attributes
706
+ child_span.set_attribute(FiddlerSpanAttributes.LLM_INPUT_SYSTEM, prompts[0])
707
+ child_span.set_attribute(FiddlerSpanAttributes.LLM_INPUT_USER, prompts[0])
708
+ self._set_session_id(child_span)
709
+ self._add_span(child_span, run_id)
710
+
711
+ def on_llm_end(
712
+ self,
713
+ response: LLMResult,
714
+ *,
715
+ run_id: UUID,
716
+ parent_run_id: UUID | None = None,
717
+ **kwargs: Any,
718
+ ) -> Any:
719
+ """Called when a language model ends.
720
+
721
+ This method finds the corresponding span, records the model's
722
+ response, sets the status to OK, and ends the span.
723
+
724
+ Args:
725
+ response: The response from the language model.
726
+ run_id: The unique ID of the language model run.
727
+ parent_run_id: The ID of the parent run, if any.
728
+ **kwargs: Additional keyword arguments.
729
+ """
730
+ span = self._get_span(run_id)
731
+ if span:
732
+ span.set_status(trace.Status(trace.StatusCode.OK))
733
+
734
+ # assuming we are going to use the first generation for now
735
+ # we always get only one element in the list - even with batch mode
736
+ # Add safety checks to prevent index errors
737
+ output = ''
738
+ if (
739
+ response.generations
740
+ and len(response.generations) > 0
741
+ and response.generations[0]
742
+ and len(response.generations[0]) > 0
743
+ ):
744
+ generation = response.generations[0][0]
745
+ output = generation.text
746
+ if (
747
+ output == ''
748
+ and isinstance(generation, ChatGeneration)
749
+ and isinstance(generation.message, AIMessage)
750
+ and hasattr(generation.message, 'tool_calls')
751
+ ):
752
+ # if llm returns an empty string, it means it used a tool
753
+ # we are using the tool calls to get the output
754
+ output = json.dumps(generation.message.tool_calls, cls=_LanggraphJSONEncoder)
755
+
756
+ span.set_attribute(FiddlerSpanAttributes.LLM_OUTPUT, output)
757
+
758
+ # Extract and set token usage information
759
+ _set_token_usage_attributes(span, response)
760
+
761
+ span.end()
762
+ self._remove_span(run_id)
763
+ else:
764
+ logger.warning('on_llm_end no active span: %s, %s', run_id, kwargs)
765
+
766
+ def on_llm_error(
767
+ self,
768
+ error: BaseException,
769
+ *,
770
+ run_id: UUID,
771
+ parent_run_id: UUID | None = None,
772
+ **kwargs: Any,
773
+ ) -> Any:
774
+ """Called when a language model encounters an error.
775
+
776
+ This method finds the corresponding span, records the exception, sets
777
+ the status to ERROR, and ends the span.
778
+
779
+ Args:
780
+ error: The exception that occurred.
781
+ run_id: The unique ID of the language model run.
782
+ parent_run_id: The ID of the parent run, if any.
783
+ **kwargs: Additional keyword arguments.
784
+ """
785
+ span = self._get_span(run_id)
786
+ if span:
787
+ span.record_exception(error)
788
+ # Use repr() for more complete error information, fallback to str() if repr() is empty
789
+ error_message = repr(error) if repr(error) else str(error)
790
+ span.set_status(trace.Status(trace.StatusCode.ERROR, error_message))
791
+ span.set_attribute('error_kwargs', str(kwargs))
792
+ span.end()
793
+ self._remove_span(run_id)
794
+ else:
795
+ logger.warning('on_llm_error no active span: %s, %s', run_id, kwargs)