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,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
+ )