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.
- fiddler_langgraph/VERSION +1 -0
- fiddler_langgraph/__init__.py +11 -0
- fiddler_langgraph/core/__init__.py +1 -0
- fiddler_langgraph/core/attributes.py +87 -0
- fiddler_langgraph/core/client.py +318 -0
- fiddler_langgraph/core/span_processor.py +31 -0
- fiddler_langgraph/tracing/__init__.py +1 -0
- fiddler_langgraph/tracing/callback.py +795 -0
- fiddler_langgraph/tracing/instrumentation.py +264 -0
- fiddler_langgraph/tracing/jsonl_capture.py +185 -0
- fiddler_langgraph/tracing/util.py +83 -0
- fiddler_langgraph-0.1.0rc1.dist-info/METADATA +323 -0
- fiddler_langgraph-0.1.0rc1.dist-info/RECORD +15 -0
- fiddler_langgraph-0.1.0rc1.dist-info/WHEEL +5 -0
- fiddler_langgraph-0.1.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""LangGraph instrumentation module for Fiddler."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Collection
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from langchain_core.callbacks import BaseCallbackManager
|
|
7
|
+
from langchain_core.language_models import BaseLanguageModel
|
|
8
|
+
from langchain_core.retrievers import BaseRetriever
|
|
9
|
+
from langchain_core.runnables import RunnableBinding
|
|
10
|
+
from langchain_core.tools import BaseTool
|
|
11
|
+
from opentelemetry.instrumentation.instrumentor import ( # type: ignore[attr-defined]
|
|
12
|
+
BaseInstrumentor,
|
|
13
|
+
)
|
|
14
|
+
from pydantic import ConfigDict, validate_call
|
|
15
|
+
from wrapt import wrap_function_wrapper
|
|
16
|
+
|
|
17
|
+
from fiddler_langgraph.core.attributes import (
|
|
18
|
+
_CONVERSATION_ID,
|
|
19
|
+
FIDDLER_METADATA_KEY,
|
|
20
|
+
FiddlerSpanAttributes,
|
|
21
|
+
)
|
|
22
|
+
from fiddler_langgraph.core.client import FiddlerClient
|
|
23
|
+
from fiddler_langgraph.tracing.callback import _CallbackHandler
|
|
24
|
+
from fiddler_langgraph.tracing.util import _check_langgraph_version, _get_package_version
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@validate_call(config=ConfigDict(strict=True))
|
|
28
|
+
def set_conversation_id(conversation_id: str) -> None:
|
|
29
|
+
"""Set the conversation ID for the current application invocation.
|
|
30
|
+
This will remain in use until it is called again with a new conversation ID.
|
|
31
|
+
Note (Robin 11th Sep 2025): This should be moved to the core.attributes module in the future.
|
|
32
|
+
"""
|
|
33
|
+
_CONVERSATION_ID.set(conversation_id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@validate_call(config=ConfigDict(strict=True, arbitrary_types_allowed=True))
|
|
37
|
+
def _set_default_metadata(
|
|
38
|
+
node: BaseLanguageModel | BaseRetriever | BaseTool,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Ensures a node has the default Fiddler metadata dictionary.
|
|
41
|
+
|
|
42
|
+
If `node.metadata` does not exist or is not a dictionary, it will be
|
|
43
|
+
initialized. This function modifies the node in place.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
node (BaseLanguageModel | BaseRetriever | BaseTool): The node to modify.
|
|
47
|
+
"""
|
|
48
|
+
if not hasattr(node, 'metadata'):
|
|
49
|
+
node.metadata = {}
|
|
50
|
+
if not isinstance(node.metadata, dict):
|
|
51
|
+
node.metadata = {}
|
|
52
|
+
metadata = node.metadata
|
|
53
|
+
if FIDDLER_METADATA_KEY not in metadata:
|
|
54
|
+
metadata[FIDDLER_METADATA_KEY] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@validate_call(config=ConfigDict(strict=True, arbitrary_types_allowed=True))
|
|
58
|
+
def add_span_attributes(
|
|
59
|
+
node: BaseLanguageModel | BaseRetriever | BaseTool,
|
|
60
|
+
**kwargs: Any,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Adds Fiddler-specific attributes to a runnable's metadata.
|
|
63
|
+
|
|
64
|
+
This is used for various runnable types like LLM calls, tool
|
|
65
|
+
calls, and retriever calls.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
node (BaseLanguageModel | BaseRetriever | BaseTool): The runnable node.
|
|
69
|
+
**kwargs: The attributes to add as key-value pairs.
|
|
70
|
+
"""
|
|
71
|
+
_set_default_metadata(node)
|
|
72
|
+
metadata = cast(dict[str, Any], node.metadata)
|
|
73
|
+
fiddler_attrs = cast(dict[str, Any], metadata.get(FIDDLER_METADATA_KEY, {}))
|
|
74
|
+
for key, value in kwargs.items():
|
|
75
|
+
fiddler_attrs[key] = value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@validate_call(config=ConfigDict(strict=True))
|
|
79
|
+
def set_llm_context(llm: BaseLanguageModel | RunnableBinding, context: str) -> None:
|
|
80
|
+
"""Sets a context string on a language model instance.
|
|
81
|
+
If the language model is a RunnableBinding, the context will be set on the bound object.
|
|
82
|
+
|
|
83
|
+
https://python.langchain.com/api_reference/core/runnables/langchain_core.runnables.base.RunnableBinding.html
|
|
84
|
+
|
|
85
|
+
The bound object of the RunnableBinding must be a BaseLanguageModel.
|
|
86
|
+
This context can be used to provide additional information about the
|
|
87
|
+
environment or data that the language model is being used in. This
|
|
88
|
+
information will be attached to the spans created for this model.
|
|
89
|
+
In case the user passes a RunnableBinding, the context will be set on the
|
|
90
|
+
bound object.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
llm (BaseLanguageModel | RunnableBinding): The language model instance. **Required**.
|
|
94
|
+
context (str): The context string to add. **Required**.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
>>> from langchain_openai import ChatOpenAI
|
|
98
|
+
>>> from fiddler_langgraph.tracing.instrumentation import set_llm_context
|
|
99
|
+
>>>
|
|
100
|
+
>>> llm = ChatOpenAI()
|
|
101
|
+
>>> set_llm_context(llm, "This is a test context.")
|
|
102
|
+
>>>
|
|
103
|
+
>>> # If you are using a RunnableBinding, you can pass the bound object
|
|
104
|
+
>>> # directly to set_llm_context.
|
|
105
|
+
>>> bound_llm = llm.bind(x=1)
|
|
106
|
+
>>> set_llm_context(bound_llm, "This is a test context.")
|
|
107
|
+
"""
|
|
108
|
+
if isinstance(llm, RunnableBinding):
|
|
109
|
+
if not isinstance(llm.bound, BaseLanguageModel):
|
|
110
|
+
raise TypeError(
|
|
111
|
+
'llm must be a BaseLanguageModel or a RunnableBinding of a BaseLanguageModel'
|
|
112
|
+
)
|
|
113
|
+
# RunnableBinding has config attribute (which can store metadata), however these are not passed
|
|
114
|
+
# to the callback handlers. So we need to use the bound object directly.
|
|
115
|
+
_llm = llm.bound
|
|
116
|
+
else:
|
|
117
|
+
_llm = llm
|
|
118
|
+
|
|
119
|
+
_set_default_metadata(_llm)
|
|
120
|
+
|
|
121
|
+
if _llm.metadata is None:
|
|
122
|
+
_llm.metadata = {}
|
|
123
|
+
fiddler_attrs = cast(dict[str, Any], _llm.metadata.get(FIDDLER_METADATA_KEY, {}))
|
|
124
|
+
fiddler_attrs[FiddlerSpanAttributes.LLM_CONTEXT] = context
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LangGraphInstrumentor(BaseInstrumentor):
|
|
128
|
+
"""An OpenTelemetry instrumentor for LangGraph applications.
|
|
129
|
+
|
|
130
|
+
This class provides automatic instrumentation for applications built with
|
|
131
|
+
LangGraph. It captures traces from the execution of LangGraph graphs and
|
|
132
|
+
sends them to the Fiddler platform.
|
|
133
|
+
|
|
134
|
+
To use the instrumentor, you first need to create a `FiddlerClient`
|
|
135
|
+
instance. Then, you can create an instance of `LangGraphInstrumentor` and
|
|
136
|
+
call the `instrument()` method.
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
>>> from fiddler_langgraph import FiddlerClient
|
|
140
|
+
>>> from fiddler_langgraph.tracing import LangGraphInstrumentor
|
|
141
|
+
>>>
|
|
142
|
+
>>> client = FiddlerClient(api_key="...", application_id="...")
|
|
143
|
+
>>> instrumentor = LangGraphInstrumentor(client=client)
|
|
144
|
+
>>> instrumentor.instrument()
|
|
145
|
+
|
|
146
|
+
Attributes:
|
|
147
|
+
_client (FiddlerClient): The FiddlerClient instance used for configuration.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, client: FiddlerClient):
|
|
151
|
+
"""Initializes the LangGraphInstrumentor.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
client (FiddlerClient): The `FiddlerClient` instance. **Required**.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
ImportError: If LangGraph version is incompatible or not installed.
|
|
158
|
+
"""
|
|
159
|
+
super().__init__()
|
|
160
|
+
self._client = client
|
|
161
|
+
self._langgraph_version = _get_package_version('langgraph')
|
|
162
|
+
self._langchain_version = _get_package_version('langchain_core')
|
|
163
|
+
self._fiddler_langgraph_version = _get_package_version('fiddler_langgraph')
|
|
164
|
+
|
|
165
|
+
self._client.update_resource(
|
|
166
|
+
{
|
|
167
|
+
'lib.langgraph.version': self._langgraph_version.public,
|
|
168
|
+
'lib.langchain_core.version': self._langchain_version.public,
|
|
169
|
+
'lib.fiddler-langgraph.version': self._fiddler_langgraph_version.public,
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
self._tracer: _CallbackHandler | None = None
|
|
173
|
+
self._original_callback_manager_init: Callable[..., None] | None = None
|
|
174
|
+
|
|
175
|
+
# Check LangGraph version compatibility - we don't add this to dependencies
|
|
176
|
+
# because we leave it to the user to install the correct version of LangGraph
|
|
177
|
+
# We will check if the user installed version is compatible with the version of fiddler-langgraph
|
|
178
|
+
_check_langgraph_version(self._langgraph_version)
|
|
179
|
+
|
|
180
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
|
181
|
+
"""Returns the package dependencies required for this instrumentor.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Collection[str]: A collection of package dependency strings.
|
|
185
|
+
"""
|
|
186
|
+
return ('langchain_core >= 0.1.0',)
|
|
187
|
+
|
|
188
|
+
def _instrument(self, **kwargs: Any) -> None:
|
|
189
|
+
"""Instruments LangGraph by monkey-patching `BaseCallbackManager`.
|
|
190
|
+
|
|
191
|
+
This method injects a custom callback handler into LangGraph's callback
|
|
192
|
+
system to capture trace data. This is done by wrapping the `__init__`
|
|
193
|
+
method of `BaseCallbackManager` to inject a `_CallbackHandler`.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: If the tracer is not initialized in the FiddlerClient.
|
|
197
|
+
"""
|
|
198
|
+
import langchain_core
|
|
199
|
+
|
|
200
|
+
tracer = self._client.get_tracer()
|
|
201
|
+
if tracer is None:
|
|
202
|
+
raise ValueError('Context tracer is not initialized')
|
|
203
|
+
|
|
204
|
+
self._tracer = _CallbackHandler(tracer)
|
|
205
|
+
self._original_callback_manager_init = langchain_core.callbacks.BaseCallbackManager.__init__
|
|
206
|
+
wrap_function_wrapper(
|
|
207
|
+
module='langchain_core.callbacks',
|
|
208
|
+
name='BaseCallbackManager.__init__',
|
|
209
|
+
wrapper=_BaseCallbackManagerInit(self._tracer),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _uninstrument(self, **kwargs: Any) -> None:
|
|
213
|
+
"""Removes the instrumentation from LangGraph.
|
|
214
|
+
|
|
215
|
+
This is done by restoring the original `__init__` method on the
|
|
216
|
+
`BaseCallbackManager` class.
|
|
217
|
+
"""
|
|
218
|
+
import langchain_core
|
|
219
|
+
|
|
220
|
+
if self._original_callback_manager_init is not None:
|
|
221
|
+
setattr( # noqa: B010
|
|
222
|
+
langchain_core.callbacks.BaseCallbackManager,
|
|
223
|
+
'__init__',
|
|
224
|
+
self._original_callback_manager_init,
|
|
225
|
+
)
|
|
226
|
+
self._original_callback_manager_init = None
|
|
227
|
+
self._tracer = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _BaseCallbackManagerInit:
|
|
231
|
+
"""A wrapper class for `BaseCallbackManager.__init__` to inject Fiddler's callback handler."""
|
|
232
|
+
|
|
233
|
+
__slots__ = ('_callback_handler',)
|
|
234
|
+
|
|
235
|
+
def __init__(self, callback_handler: _CallbackHandler):
|
|
236
|
+
"""Initializes the wrapper.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
callback_handler (_CallbackHandler): The Fiddler callback handler instance
|
|
240
|
+
to be injected into the callback manager.
|
|
241
|
+
"""
|
|
242
|
+
self._callback_handler = callback_handler
|
|
243
|
+
|
|
244
|
+
def __call__(
|
|
245
|
+
self,
|
|
246
|
+
wrapped: Callable[..., None],
|
|
247
|
+
instance: 'BaseCallbackManager',
|
|
248
|
+
args: Any,
|
|
249
|
+
kwargs: Any,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Calls the original `__init__` and then adds the Fiddler handler.
|
|
252
|
+
|
|
253
|
+
It also ensures that the handler is not added multiple times if it
|
|
254
|
+
already exists in the list of inheritable handlers.
|
|
255
|
+
"""
|
|
256
|
+
wrapped(*args, **kwargs)
|
|
257
|
+
for handler in instance.inheritable_handlers:
|
|
258
|
+
# Handlers may be copied when new managers are created, so we
|
|
259
|
+
# don't want to keep adding. E.g. see the following location.
|
|
260
|
+
# https://github.com/langchain-ai/langchain/blob/5c2538b9f7fb64afed2a918b621d9d8681c7ae32/libs/core/langchain_core/callbacks/manager.py#L1876
|
|
261
|
+
if isinstance(handler, type(self._callback_handler)):
|
|
262
|
+
break
|
|
263
|
+
else:
|
|
264
|
+
instance.add_handler(self._callback_handler, True)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""JSONL data capture module for simplified span data in structured format."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
12
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
13
|
+
|
|
14
|
+
from fiddler_langgraph.core.attributes import FiddlerSpanAttributes, SpanType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JSONLSpanCapture:
|
|
18
|
+
"""Captures OpenTelemetry span data and saves it to JSONL format with structured fields."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, jsonl_file_path: str | None = None):
|
|
21
|
+
"""Initialize JSONL capture.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
jsonl_file_path: Path to the JSONL file. If None, uses FIDDLER_JSONL_FILE env var (default: 'fiddler_trace_data.jsonl')
|
|
25
|
+
"""
|
|
26
|
+
if jsonl_file_path is None:
|
|
27
|
+
jsonl_file_path = os.getenv('FIDDLER_JSONL_FILE', 'fiddler_trace_data.jsonl')
|
|
28
|
+
|
|
29
|
+
self.jsonl_file_path = Path(jsonl_file_path)
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._ensure_jsonl_file()
|
|
32
|
+
|
|
33
|
+
def _ensure_jsonl_file(self) -> None:
|
|
34
|
+
"""Ensure the JSONL file exists and has proper headers."""
|
|
35
|
+
try:
|
|
36
|
+
if not self.jsonl_file_path.exists():
|
|
37
|
+
self.jsonl_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
self.jsonl_file_path.touch()
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f'Warning: Could not create JSONL file {self.jsonl_file_path}: {e}')
|
|
41
|
+
|
|
42
|
+
def capture_span(self, span: ReadableSpan) -> None:
|
|
43
|
+
"""Capture a span and write it to JSONL file."""
|
|
44
|
+
try:
|
|
45
|
+
span_data = self._convert_span_to_structured_format(span)
|
|
46
|
+
self._write_span_to_jsonl(span_data)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f'Error capturing span to JSONL: {e}')
|
|
49
|
+
|
|
50
|
+
def _convert_span_to_structured_format(self, span: ReadableSpan) -> dict[str, Any]:
|
|
51
|
+
"""Convert ReadableSpan to structured format for JSONL export."""
|
|
52
|
+
# Extract basic span information
|
|
53
|
+
span_data = {
|
|
54
|
+
'trace_id': format(span.get_span_context().trace_id, '032x'),
|
|
55
|
+
'span_id': format(span.get_span_context().span_id, '016x'),
|
|
56
|
+
'parent_span_id': format(span.parent.span_id, '016x') if span.parent else '',
|
|
57
|
+
'root_span_id': format(
|
|
58
|
+
span.get_span_context().trace_id, '032x'
|
|
59
|
+
), # Use trace_id as root_span_id
|
|
60
|
+
'span_name': span.name,
|
|
61
|
+
'span_kind': span.kind.name if span.kind else 'CLIENT',
|
|
62
|
+
'start_time': (
|
|
63
|
+
datetime.fromtimestamp(span.start_time / 1_000_000_000, tz=timezone.utc).isoformat()
|
|
64
|
+
if span.start_time is not None
|
|
65
|
+
else ''
|
|
66
|
+
),
|
|
67
|
+
'end_time': (
|
|
68
|
+
datetime.fromtimestamp(span.end_time / 1_000_000_000, tz=timezone.utc).isoformat()
|
|
69
|
+
if span.end_time is not None
|
|
70
|
+
else ''
|
|
71
|
+
),
|
|
72
|
+
'duration_ms': (
|
|
73
|
+
int((span.end_time - span.start_time) / 1_000_000)
|
|
74
|
+
if span.end_time is not None and span.start_time is not None
|
|
75
|
+
else 0
|
|
76
|
+
),
|
|
77
|
+
'status_code': span.status.status_code.name if span.status else 'OK',
|
|
78
|
+
'status_message': (
|
|
79
|
+
span.status.description if span.status and span.status.description else ''
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Extract attributes and map them to structured fields
|
|
84
|
+
attributes = dict(span.attributes) if span.attributes else {}
|
|
85
|
+
|
|
86
|
+
# Span type and agent info
|
|
87
|
+
span_data['span_type'] = attributes.get(FiddlerSpanAttributes.TYPE, SpanType.OTHER)
|
|
88
|
+
span_data['agent_name'] = attributes.get(FiddlerSpanAttributes.AGENT_NAME, '')
|
|
89
|
+
span_data['agent_id'] = attributes.get(FiddlerSpanAttributes.AGENT_ID, '')
|
|
90
|
+
span_data['conversation_id'] = attributes.get(FiddlerSpanAttributes.CONVERSATION_ID, '')
|
|
91
|
+
|
|
92
|
+
# Model information
|
|
93
|
+
span_data['model_name'] = attributes.get(FiddlerSpanAttributes.LLM_REQUEST_MODEL, '')
|
|
94
|
+
span_data['model_provider'] = attributes.get(FiddlerSpanAttributes.LLM_SYSTEM, '')
|
|
95
|
+
|
|
96
|
+
# LLM inputs/outputs
|
|
97
|
+
span_data['llm_input_system'] = attributes.get(FiddlerSpanAttributes.LLM_INPUT_SYSTEM, '')
|
|
98
|
+
span_data['llm_input_user'] = attributes.get(FiddlerSpanAttributes.LLM_INPUT_USER, '')
|
|
99
|
+
span_data['llm_output'] = attributes.get(FiddlerSpanAttributes.LLM_OUTPUT, '')
|
|
100
|
+
span_data['llm_context'] = attributes.get(FiddlerSpanAttributes.LLM_CONTEXT, '')
|
|
101
|
+
|
|
102
|
+
# Tool information
|
|
103
|
+
span_data['tool_name'] = attributes.get(FiddlerSpanAttributes.TOOL_NAME, '')
|
|
104
|
+
span_data['tool_input'] = attributes.get(FiddlerSpanAttributes.TOOL_INPUT, '')
|
|
105
|
+
span_data['tool_output'] = attributes.get(FiddlerSpanAttributes.TOOL_OUTPUT, '')
|
|
106
|
+
|
|
107
|
+
# Library versions (from resource if available)
|
|
108
|
+
resource_attributes = (
|
|
109
|
+
dict(span.resource.attributes) if span.resource and span.resource.attributes else {}
|
|
110
|
+
)
|
|
111
|
+
span_data['service_name'] = resource_attributes.get('service.name', '')
|
|
112
|
+
span_data['service_version'] = resource_attributes.get('service.version', '')
|
|
113
|
+
span_data['telemetry_sdk_name'] = resource_attributes.get('telemetry.sdk.name', '')
|
|
114
|
+
span_data['telemetry_sdk_version'] = resource_attributes.get('telemetry.sdk.version', '')
|
|
115
|
+
span_data['application_id'] = resource_attributes.get('application.id', '')
|
|
116
|
+
|
|
117
|
+
# Custom metadata and tags
|
|
118
|
+
custom_attributes = {}
|
|
119
|
+
for key, value in attributes.items():
|
|
120
|
+
if not key.startswith(('gen_ai.', 'fiddler.', 'service.', 'telemetry.')):
|
|
121
|
+
custom_attributes[key] = value
|
|
122
|
+
|
|
123
|
+
span_data['custom_attributes'] = json.dumps(custom_attributes) if custom_attributes else ''
|
|
124
|
+
|
|
125
|
+
# Exception information
|
|
126
|
+
exception_info = []
|
|
127
|
+
if hasattr(span, 'events') and span.events:
|
|
128
|
+
for event in span.events:
|
|
129
|
+
if event.name == 'exception':
|
|
130
|
+
event_attrs = dict(event.attributes) if event.attributes else {}
|
|
131
|
+
exception_info.append(
|
|
132
|
+
{
|
|
133
|
+
'type': event_attrs.get('exception.type', ''),
|
|
134
|
+
'message': event_attrs.get('exception.message', ''),
|
|
135
|
+
'stacktrace': event_attrs.get('exception.stacktrace', ''),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
span_data['exception_info'] = json.dumps(exception_info) if exception_info else ''
|
|
140
|
+
|
|
141
|
+
return span_data
|
|
142
|
+
|
|
143
|
+
def _write_span_to_jsonl(self, span_data: dict[str, Any]) -> None:
|
|
144
|
+
"""Write span data to JSONL file."""
|
|
145
|
+
with self._lock:
|
|
146
|
+
try:
|
|
147
|
+
with self.jsonl_file_path.open('a', encoding='utf-8') as f:
|
|
148
|
+
json.dump(span_data, f, ensure_ascii=False)
|
|
149
|
+
f.write('\n')
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f'Error writing to JSONL file {self.jsonl_file_path}: {e}')
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class JSONLSpanExporter(SpanExporter):
|
|
155
|
+
"""SpanExporter that captures spans using JSONLSpanCapture."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, jsonl_capture: JSONLSpanCapture):
|
|
158
|
+
"""Initialize the exporter.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
jsonl_capture: The JSONLSpanCapture instance to use for capturing spans
|
|
162
|
+
"""
|
|
163
|
+
self.jsonl_capture = jsonl_capture
|
|
164
|
+
|
|
165
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
166
|
+
"""Export spans by capturing them with JSONLSpanCapture."""
|
|
167
|
+
try:
|
|
168
|
+
for span in spans:
|
|
169
|
+
self.jsonl_capture.capture_span(span)
|
|
170
|
+
return SpanExportResult.SUCCESS
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(f'Error exporting spans to JSONL: {e}')
|
|
173
|
+
return SpanExportResult.FAILURE
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def initialize_jsonl_capture(jsonl_file_path: str | None = None) -> JSONLSpanCapture:
|
|
177
|
+
"""Initialize a JSONLSpanCapture instance.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
jsonl_file_path: Path to the JSONL file. If None, uses FIDDLER_JSONL_FILE env var
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
JSONLSpanCapture: The initialized capture instance
|
|
184
|
+
"""
|
|
185
|
+
return JSONLSpanCapture(jsonl_file_path=jsonl_file_path)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import importlib.metadata
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from packaging import version as pkg_version
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _LanggraphJSONEncoder(json.JSONEncoder):
|
|
15
|
+
"""A custom JSON encoder for LangGraph objects.
|
|
16
|
+
|
|
17
|
+
This encoder handles the serialization of common LangGraph and Pydantic
|
|
18
|
+
objects into JSON-serializable formats.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# pylint: disable=too-many-return-statements
|
|
22
|
+
def default(self, o: Any) -> Any:
|
|
23
|
+
"""Serializes an object to a JSON-compatible format.
|
|
24
|
+
|
|
25
|
+
This method provides custom serialization for the following types:
|
|
26
|
+
- Dataclasses
|
|
27
|
+
- Objects with a `to_json` method
|
|
28
|
+
- Pydantic models
|
|
29
|
+
- Datetime objects
|
|
30
|
+
|
|
31
|
+
If an object cannot be serialized, it is converted to an empty string.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
o: The object to serialize.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A JSON-serializable representation of the object.
|
|
38
|
+
"""
|
|
39
|
+
if is_dataclass(o):
|
|
40
|
+
return asdict(o) # type: ignore[arg-type]
|
|
41
|
+
|
|
42
|
+
if hasattr(o, 'to_json'):
|
|
43
|
+
return o.to_json()
|
|
44
|
+
|
|
45
|
+
if isinstance(o, BaseModel) and hasattr(o, 'model_dump_json'):
|
|
46
|
+
return o.model_dump_json()
|
|
47
|
+
|
|
48
|
+
if isinstance(o, datetime.datetime):
|
|
49
|
+
return o.isoformat()
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return str(o)
|
|
53
|
+
except (TypeError, ValueError) as e:
|
|
54
|
+
logger.debug('Failed to serialize object of type %s: %s', type(o).__name__, str(e))
|
|
55
|
+
return ''
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_package_version(package_name: str) -> pkg_version.Version:
|
|
59
|
+
"""Get the version of a package."""
|
|
60
|
+
try:
|
|
61
|
+
version = importlib.metadata.version(package_name)
|
|
62
|
+
return pkg_version.parse(version)
|
|
63
|
+
except importlib.metadata.PackageNotFoundError:
|
|
64
|
+
raise ImportError(f'Package {package_name} is not installed')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_langgraph_version(
|
|
68
|
+
langgraph_version: pkg_version.Version,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Check if the installed LangGraph version is compatible with the version of fiddler-langgraph."""
|
|
71
|
+
|
|
72
|
+
if langgraph_version is None:
|
|
73
|
+
raise ImportError('Either langgraph or langchain_core should be installed')
|
|
74
|
+
|
|
75
|
+
# check compatibility range
|
|
76
|
+
min_langgraph_version = pkg_version.parse('0.3.28')
|
|
77
|
+
max_langgraph_version = pkg_version.parse('1.1.0')
|
|
78
|
+
|
|
79
|
+
if langgraph_version < min_langgraph_version or langgraph_version > max_langgraph_version:
|
|
80
|
+
raise ImportError(
|
|
81
|
+
f'langgraph version {langgraph_version.public} is not compatible. '
|
|
82
|
+
f'fiddler-langgraph requires langgraph >= 0.3.28 and < 1.1.0. '
|
|
83
|
+
)
|