opik 1.9.5__py3-none-any.whl → 1.9.39__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.
Files changed (248) hide show
  1. opik/__init__.py +10 -3
  2. opik/anonymizer/__init__.py +5 -0
  3. opik/anonymizer/anonymizer.py +12 -0
  4. opik/anonymizer/factory.py +80 -0
  5. opik/anonymizer/recursive_anonymizer.py +64 -0
  6. opik/anonymizer/rules.py +56 -0
  7. opik/anonymizer/rules_anonymizer.py +35 -0
  8. opik/api_objects/dataset/rest_operations.py +5 -0
  9. opik/api_objects/experiment/experiment.py +46 -49
  10. opik/api_objects/experiment/helpers.py +34 -10
  11. opik/api_objects/local_recording.py +8 -3
  12. opik/api_objects/opik_client.py +230 -48
  13. opik/api_objects/opik_query_language.py +9 -0
  14. opik/api_objects/prompt/__init__.py +11 -3
  15. opik/api_objects/prompt/base_prompt.py +69 -0
  16. opik/api_objects/prompt/base_prompt_template.py +29 -0
  17. opik/api_objects/prompt/chat/__init__.py +1 -0
  18. opik/api_objects/prompt/chat/chat_prompt.py +193 -0
  19. opik/api_objects/prompt/chat/chat_prompt_template.py +350 -0
  20. opik/api_objects/prompt/{chat_content_renderer_registry.py → chat/content_renderer_registry.py} +37 -35
  21. opik/api_objects/prompt/client.py +101 -30
  22. opik/api_objects/prompt/text/__init__.py +1 -0
  23. opik/api_objects/prompt/text/prompt.py +174 -0
  24. opik/api_objects/prompt/{prompt_template.py → text/prompt_template.py} +10 -6
  25. opik/api_objects/prompt/types.py +1 -1
  26. opik/cli/export.py +6 -2
  27. opik/cli/usage_report/charts.py +39 -10
  28. opik/cli/usage_report/cli.py +164 -45
  29. opik/cli/usage_report/pdf.py +14 -1
  30. opik/config.py +0 -5
  31. opik/decorator/base_track_decorator.py +37 -40
  32. opik/decorator/context_manager/span_context_manager.py +9 -0
  33. opik/decorator/context_manager/trace_context_manager.py +5 -0
  34. opik/dict_utils.py +3 -3
  35. opik/evaluation/__init__.py +13 -2
  36. opik/evaluation/engine/engine.py +195 -223
  37. opik/evaluation/engine/helpers.py +8 -7
  38. opik/evaluation/engine/metrics_evaluator.py +237 -0
  39. opik/evaluation/evaluation_result.py +35 -1
  40. opik/evaluation/evaluator.py +318 -30
  41. opik/evaluation/models/litellm/util.py +78 -6
  42. opik/evaluation/models/model_capabilities.py +33 -0
  43. opik/evaluation/report.py +14 -2
  44. opik/evaluation/rest_operations.py +36 -33
  45. opik/evaluation/test_case.py +2 -2
  46. opik/evaluation/types.py +9 -1
  47. opik/exceptions.py +17 -0
  48. opik/hooks/__init__.py +17 -1
  49. opik/hooks/anonymizer_hook.py +36 -0
  50. opik/id_helpers.py +18 -0
  51. opik/integrations/adk/helpers.py +16 -7
  52. opik/integrations/adk/legacy_opik_tracer.py +7 -4
  53. opik/integrations/adk/opik_tracer.py +3 -1
  54. opik/integrations/adk/patchers/adk_otel_tracer/opik_adk_otel_tracer.py +7 -3
  55. opik/integrations/adk/recursive_callback_injector.py +1 -6
  56. opik/integrations/dspy/callback.py +1 -4
  57. opik/integrations/haystack/opik_connector.py +2 -2
  58. opik/integrations/haystack/opik_tracer.py +2 -4
  59. opik/integrations/langchain/opik_tracer.py +273 -82
  60. opik/integrations/llama_index/callback.py +110 -108
  61. opik/integrations/openai/agents/opik_tracing_processor.py +1 -2
  62. opik/integrations/openai/opik_tracker.py +1 -1
  63. opik/message_processing/batching/batchers.py +11 -7
  64. opik/message_processing/encoder_helpers.py +79 -0
  65. opik/message_processing/messages.py +25 -1
  66. opik/message_processing/online_message_processor.py +23 -8
  67. opik/opik_context.py +7 -7
  68. opik/rest_api/__init__.py +188 -12
  69. opik/rest_api/client.py +3 -0
  70. opik/rest_api/dashboards/__init__.py +4 -0
  71. opik/rest_api/dashboards/client.py +462 -0
  72. opik/rest_api/dashboards/raw_client.py +648 -0
  73. opik/rest_api/datasets/client.py +893 -89
  74. opik/rest_api/datasets/raw_client.py +1328 -87
  75. opik/rest_api/experiments/client.py +30 -2
  76. opik/rest_api/experiments/raw_client.py +26 -0
  77. opik/rest_api/feedback_definitions/types/find_feedback_definitions_request_type.py +1 -1
  78. opik/rest_api/optimizations/client.py +302 -0
  79. opik/rest_api/optimizations/raw_client.py +463 -0
  80. opik/rest_api/optimizations/types/optimization_update_status.py +3 -1
  81. opik/rest_api/prompts/__init__.py +2 -2
  82. opik/rest_api/prompts/client.py +34 -4
  83. opik/rest_api/prompts/raw_client.py +32 -2
  84. opik/rest_api/prompts/types/__init__.py +3 -1
  85. opik/rest_api/prompts/types/create_prompt_version_detail_template_structure.py +5 -0
  86. opik/rest_api/prompts/types/prompt_write_template_structure.py +5 -0
  87. opik/rest_api/spans/__init__.py +0 -2
  88. opik/rest_api/spans/client.py +148 -64
  89. opik/rest_api/spans/raw_client.py +210 -83
  90. opik/rest_api/spans/types/__init__.py +0 -2
  91. opik/rest_api/traces/client.py +241 -73
  92. opik/rest_api/traces/raw_client.py +344 -90
  93. opik/rest_api/types/__init__.py +200 -15
  94. opik/rest_api/types/aggregation_data.py +1 -0
  95. opik/rest_api/types/alert_trigger_config_public_type.py +6 -1
  96. opik/rest_api/types/alert_trigger_config_type.py +6 -1
  97. opik/rest_api/types/alert_trigger_config_write_type.py +6 -1
  98. opik/rest_api/types/automation_rule_evaluator.py +23 -1
  99. opik/rest_api/types/automation_rule_evaluator_llm_as_judge.py +2 -0
  100. opik/rest_api/types/automation_rule_evaluator_llm_as_judge_public.py +2 -0
  101. opik/rest_api/types/automation_rule_evaluator_llm_as_judge_write.py +2 -0
  102. opik/rest_api/types/{automation_rule_evaluator_object_public.py → automation_rule_evaluator_object_object_public.py} +32 -10
  103. opik/rest_api/types/automation_rule_evaluator_page_public.py +2 -2
  104. opik/rest_api/types/automation_rule_evaluator_public.py +23 -1
  105. opik/rest_api/types/automation_rule_evaluator_span_llm_as_judge.py +22 -0
  106. opik/rest_api/types/automation_rule_evaluator_span_llm_as_judge_public.py +22 -0
  107. opik/rest_api/types/automation_rule_evaluator_span_llm_as_judge_write.py +22 -0
  108. opik/rest_api/types/automation_rule_evaluator_trace_thread_llm_as_judge.py +2 -0
  109. opik/rest_api/types/automation_rule_evaluator_trace_thread_llm_as_judge_public.py +2 -0
  110. opik/rest_api/types/automation_rule_evaluator_trace_thread_llm_as_judge_write.py +2 -0
  111. opik/rest_api/types/automation_rule_evaluator_trace_thread_user_defined_metric_python.py +2 -0
  112. opik/rest_api/types/automation_rule_evaluator_trace_thread_user_defined_metric_python_public.py +2 -0
  113. opik/rest_api/types/automation_rule_evaluator_trace_thread_user_defined_metric_python_write.py +2 -0
  114. opik/rest_api/types/automation_rule_evaluator_update.py +23 -1
  115. opik/rest_api/types/automation_rule_evaluator_update_llm_as_judge.py +2 -0
  116. opik/rest_api/types/automation_rule_evaluator_update_span_llm_as_judge.py +22 -0
  117. opik/rest_api/types/automation_rule_evaluator_update_trace_thread_llm_as_judge.py +2 -0
  118. opik/rest_api/types/automation_rule_evaluator_update_trace_thread_user_defined_metric_python.py +2 -0
  119. opik/rest_api/types/automation_rule_evaluator_update_user_defined_metric_python.py +2 -0
  120. opik/rest_api/types/automation_rule_evaluator_user_defined_metric_python.py +2 -0
  121. opik/rest_api/types/automation_rule_evaluator_user_defined_metric_python_public.py +2 -0
  122. opik/rest_api/types/automation_rule_evaluator_user_defined_metric_python_write.py +2 -0
  123. opik/rest_api/types/automation_rule_evaluator_write.py +23 -1
  124. opik/rest_api/types/boolean_feedback_definition.py +25 -0
  125. opik/rest_api/types/boolean_feedback_definition_create.py +20 -0
  126. opik/rest_api/types/boolean_feedback_definition_public.py +25 -0
  127. opik/rest_api/types/boolean_feedback_definition_update.py +20 -0
  128. opik/rest_api/types/boolean_feedback_detail.py +29 -0
  129. opik/rest_api/types/boolean_feedback_detail_create.py +29 -0
  130. opik/rest_api/types/boolean_feedback_detail_public.py +29 -0
  131. opik/rest_api/types/boolean_feedback_detail_update.py +29 -0
  132. opik/rest_api/types/dashboard_page_public.py +24 -0
  133. opik/rest_api/types/dashboard_public.py +30 -0
  134. opik/rest_api/types/dataset.py +2 -0
  135. opik/rest_api/types/dataset_item.py +2 -0
  136. opik/rest_api/types/dataset_item_compare.py +2 -0
  137. opik/rest_api/types/dataset_item_filter.py +23 -0
  138. opik/rest_api/types/dataset_item_filter_operator.py +21 -0
  139. opik/rest_api/types/dataset_item_page_compare.py +1 -0
  140. opik/rest_api/types/dataset_item_page_public.py +1 -0
  141. opik/rest_api/types/dataset_item_public.py +2 -0
  142. opik/rest_api/types/dataset_item_update.py +39 -0
  143. opik/rest_api/types/dataset_item_write.py +1 -0
  144. opik/rest_api/types/dataset_public.py +2 -0
  145. opik/rest_api/types/dataset_public_status.py +5 -0
  146. opik/rest_api/types/dataset_status.py +5 -0
  147. opik/rest_api/types/dataset_version_diff.py +22 -0
  148. opik/rest_api/types/dataset_version_diff_stats.py +24 -0
  149. opik/rest_api/types/dataset_version_page_public.py +23 -0
  150. opik/rest_api/types/dataset_version_public.py +49 -0
  151. opik/rest_api/types/experiment.py +2 -0
  152. opik/rest_api/types/experiment_public.py +2 -0
  153. opik/rest_api/types/experiment_score.py +20 -0
  154. opik/rest_api/types/experiment_score_public.py +20 -0
  155. opik/rest_api/types/experiment_score_write.py +20 -0
  156. opik/rest_api/types/feedback.py +20 -1
  157. opik/rest_api/types/feedback_create.py +16 -1
  158. opik/rest_api/types/feedback_object_public.py +22 -1
  159. opik/rest_api/types/feedback_public.py +20 -1
  160. opik/rest_api/types/feedback_score_public.py +4 -0
  161. opik/rest_api/types/feedback_update.py +16 -1
  162. opik/rest_api/types/image_url.py +20 -0
  163. opik/rest_api/types/image_url_public.py +20 -0
  164. opik/rest_api/types/image_url_write.py +20 -0
  165. opik/rest_api/types/llm_as_judge_message.py +5 -1
  166. opik/rest_api/types/llm_as_judge_message_content.py +24 -0
  167. opik/rest_api/types/llm_as_judge_message_content_public.py +24 -0
  168. opik/rest_api/types/llm_as_judge_message_content_write.py +24 -0
  169. opik/rest_api/types/llm_as_judge_message_public.py +5 -1
  170. opik/rest_api/types/llm_as_judge_message_write.py +5 -1
  171. opik/rest_api/types/llm_as_judge_model_parameters.py +2 -0
  172. opik/rest_api/types/llm_as_judge_model_parameters_public.py +2 -0
  173. opik/rest_api/types/llm_as_judge_model_parameters_write.py +2 -0
  174. opik/rest_api/types/optimization.py +2 -0
  175. opik/rest_api/types/optimization_public.py +2 -0
  176. opik/rest_api/types/optimization_public_status.py +3 -1
  177. opik/rest_api/types/optimization_status.py +3 -1
  178. opik/rest_api/types/optimization_studio_config.py +27 -0
  179. opik/rest_api/types/optimization_studio_config_public.py +27 -0
  180. opik/rest_api/types/optimization_studio_config_write.py +27 -0
  181. opik/rest_api/types/optimization_studio_log.py +22 -0
  182. opik/rest_api/types/optimization_write.py +2 -0
  183. opik/rest_api/types/optimization_write_status.py +3 -1
  184. opik/rest_api/types/prompt.py +6 -0
  185. opik/rest_api/types/prompt_detail.py +6 -0
  186. opik/rest_api/types/prompt_detail_template_structure.py +5 -0
  187. opik/rest_api/types/prompt_public.py +6 -0
  188. opik/rest_api/types/prompt_public_template_structure.py +5 -0
  189. opik/rest_api/types/prompt_template_structure.py +5 -0
  190. opik/rest_api/types/prompt_version.py +2 -0
  191. opik/rest_api/types/prompt_version_detail.py +2 -0
  192. opik/rest_api/types/prompt_version_detail_template_structure.py +5 -0
  193. opik/rest_api/types/prompt_version_public.py +2 -0
  194. opik/rest_api/types/prompt_version_public_template_structure.py +5 -0
  195. opik/rest_api/types/prompt_version_template_structure.py +5 -0
  196. opik/rest_api/types/score_name.py +1 -0
  197. opik/rest_api/types/service_toggles_config.py +6 -0
  198. opik/rest_api/types/span_enrichment_options.py +31 -0
  199. opik/rest_api/types/span_filter.py +23 -0
  200. opik/rest_api/types/span_filter_operator.py +21 -0
  201. opik/rest_api/types/span_filter_write.py +23 -0
  202. opik/rest_api/types/span_filter_write_operator.py +21 -0
  203. opik/rest_api/types/span_llm_as_judge_code.py +27 -0
  204. opik/rest_api/types/span_llm_as_judge_code_public.py +27 -0
  205. opik/rest_api/types/span_llm_as_judge_code_write.py +27 -0
  206. opik/rest_api/types/span_update.py +46 -0
  207. opik/rest_api/types/studio_evaluation.py +20 -0
  208. opik/rest_api/types/studio_evaluation_public.py +20 -0
  209. opik/rest_api/types/studio_evaluation_write.py +20 -0
  210. opik/rest_api/types/studio_llm_model.py +21 -0
  211. opik/rest_api/types/studio_llm_model_public.py +21 -0
  212. opik/rest_api/types/studio_llm_model_write.py +21 -0
  213. opik/rest_api/types/studio_message.py +20 -0
  214. opik/rest_api/types/studio_message_public.py +20 -0
  215. opik/rest_api/types/studio_message_write.py +20 -0
  216. opik/rest_api/types/studio_metric.py +21 -0
  217. opik/rest_api/types/studio_metric_public.py +21 -0
  218. opik/rest_api/types/studio_metric_write.py +21 -0
  219. opik/rest_api/types/studio_optimizer.py +21 -0
  220. opik/rest_api/types/studio_optimizer_public.py +21 -0
  221. opik/rest_api/types/studio_optimizer_write.py +21 -0
  222. opik/rest_api/types/studio_prompt.py +20 -0
  223. opik/rest_api/types/studio_prompt_public.py +20 -0
  224. opik/rest_api/types/studio_prompt_write.py +20 -0
  225. opik/rest_api/types/trace.py +6 -0
  226. opik/rest_api/types/trace_public.py +6 -0
  227. opik/rest_api/types/trace_thread_filter_write.py +23 -0
  228. opik/rest_api/types/trace_thread_filter_write_operator.py +21 -0
  229. opik/rest_api/types/trace_thread_update.py +19 -0
  230. opik/rest_api/types/trace_update.py +39 -0
  231. opik/rest_api/types/value_entry.py +2 -0
  232. opik/rest_api/types/value_entry_compare.py +2 -0
  233. opik/rest_api/types/value_entry_experiment_item_bulk_write_view.py +2 -0
  234. opik/rest_api/types/value_entry_public.py +2 -0
  235. opik/rest_api/types/video_url.py +19 -0
  236. opik/rest_api/types/video_url_public.py +19 -0
  237. opik/rest_api/types/video_url_write.py +19 -0
  238. opik/synchronization.py +5 -6
  239. opik/{decorator/tracing_runtime_config.py → tracing_runtime_config.py} +6 -7
  240. {opik-1.9.5.dist-info → opik-1.9.39.dist-info}/METADATA +5 -4
  241. {opik-1.9.5.dist-info → opik-1.9.39.dist-info}/RECORD +246 -151
  242. opik/api_objects/prompt/chat_prompt_template.py +0 -164
  243. opik/api_objects/prompt/prompt.py +0 -131
  244. /opik/rest_api/{spans/types → types}/span_update_type.py +0 -0
  245. {opik-1.9.5.dist-info → opik-1.9.39.dist-info}/WHEEL +0 -0
  246. {opik-1.9.5.dist-info → opik-1.9.39.dist-info}/entry_points.txt +0 -0
  247. {opik-1.9.5.dist-info → opik-1.9.39.dist-info}/licenses/LICENSE +0 -0
  248. {opik-1.9.5.dist-info → opik-1.9.39.dist-info}/top_level.txt +0 -0
@@ -9,8 +9,8 @@ from typing import (
9
9
  Set,
10
10
  TYPE_CHECKING,
11
11
  cast,
12
- Tuple,
13
12
  Callable,
13
+ NamedTuple,
14
14
  )
15
15
  import contextvars
16
16
  from uuid import UUID
@@ -19,8 +19,7 @@ from langchain_core import language_models
19
19
  from langchain_core.tracers import BaseTracer
20
20
  from langchain_core.tracers.schemas import Run
21
21
 
22
- import opik.dict_utils as dict_utils
23
- import opik.llm_usage as llm_usage
22
+ from opik import context_storage, dict_utils, llm_usage, tracing_runtime_config
24
23
  from opik.api_objects import span, trace
25
24
  from opik.types import DistributedTraceHeadersDict, ErrorInfoDict
26
25
  from opik.validation import parameters_validator
@@ -32,8 +31,6 @@ from . import (
32
31
  )
33
32
 
34
33
  from ...api_objects import helpers, opik_client
35
- import opik.context_storage as context_storage
36
- import opik.decorator.tracing_runtime_config as tracing_runtime_config
37
34
 
38
35
  if TYPE_CHECKING:
39
36
  from langchain_core.runnables.graph import Graph
@@ -57,6 +54,11 @@ SkipErrorCallback = Callable[[str], bool]
57
54
  ERROR_SKIPPED_OUTPUTS = {"warning": "Error output skipped by skip_error_callback."}
58
55
 
59
56
 
57
+ class TrackRootRunResult(NamedTuple):
58
+ new_trace_data: Optional[trace.TraceData]
59
+ new_span_data: Optional[span.SpanData]
60
+
61
+
60
62
  def _get_span_type(run: Dict[str, Any]) -> SpanType:
61
63
  if run.get("run_type") in ["llm", "tool"]:
62
64
  return cast(SpanType, run.get("run_type"))
@@ -67,6 +69,14 @@ def _get_span_type(run: Dict[str, Any]) -> SpanType:
67
69
  return cast(SpanType, "general")
68
70
 
69
71
 
72
+ def _is_root_run(run_dict: Dict[str, Any]) -> bool:
73
+ return run_dict.get("parent_run_id") is None
74
+
75
+
76
+ def _get_run_metadata(run_dict: Dict[str, Any]) -> Dict[str, Any]:
77
+ return run_dict["extra"].get("metadata", {})
78
+
79
+
70
80
  class OpikTracer(BaseTracer):
71
81
  """Langchain Opik Tracer."""
72
82
 
@@ -132,6 +142,12 @@ class OpikTracer(BaseTracer):
132
142
 
133
143
  self._externally_created_traces_ids: Set[str] = set()
134
144
 
145
+ self._skipped_langgraph_root_run_ids: Set[UUID] = set()
146
+ """Set of run IDs for LangGraph root runs where we skip creating the span."""
147
+
148
+ self._langgraph_parent_span_ids: Dict[UUID, Optional[str]] = {}
149
+ """Map from LangGraph root run ID to parent span ID (None if attached to trace)."""
150
+
135
151
  self._project_name = project_name
136
152
 
137
153
  self._distributed_headers = distributed_headers
@@ -157,14 +173,6 @@ class OpikTracer(BaseTracer):
157
173
  )
158
174
 
159
175
  def _persist_run(self, run: Run) -> None:
160
- if run.id not in self._span_data_map:
161
- LOGGER.warning(
162
- f"Span data for run '{run.id}' not found in the span data map. Skipping processing of _persist_run."
163
- )
164
- return
165
-
166
- span_data = self._span_data_map[run.id]
167
-
168
176
  run_dict: Dict[str, Any] = run.dict()
169
177
 
170
178
  error_info: Optional[ErrorInfoDict]
@@ -187,34 +195,49 @@ class OpikTracer(BaseTracer):
187
195
  langchain_helpers.split_big_langgraph_outputs(outputs)
188
196
  )
189
197
 
198
+ self._ensure_no_hanging_opik_tracer_spans()
199
+
200
+ span_data = self._span_data_map.get(run.id)
190
201
  if (
191
- span_data.parent_span_id is not None
192
- and self._is_opik_span_created_by_this_tracer(span_data.parent_span_id)
202
+ span_data is None
203
+ or span_data.trace_id not in self._externally_created_traces_ids
193
204
  ):
194
- # Langchain lost the parent-child relationship for Run, so it calls _persist_run
195
- # for a subchain when the ACTUAL root run has not yet persisted.
196
- # However, we know that this tracer created the parent span, so we don't
197
- # want to finalize the trace
198
- return
205
+ self._finalize_trace(
206
+ run_id=run.id,
207
+ run_dict=run_dict,
208
+ trace_additional_metadata=trace_additional_metadata,
209
+ outputs=outputs,
210
+ error_info=error_info,
211
+ )
199
212
 
200
- self._ensure_no_hanging_opik_tracer_spans()
213
+ def _finalize_trace(
214
+ self,
215
+ run_id: UUID,
216
+ run_dict: Dict[str, Any],
217
+ trace_additional_metadata: Optional[Dict[str, Any]],
218
+ outputs: Optional[Dict[str, Any]],
219
+ error_info: Optional[ErrorInfoDict],
220
+ ) -> None:
221
+ trace_data = self._created_traces_data_map.get(run_id)
222
+ if trace_data is None:
223
+ LOGGER.warning(
224
+ f"Trace data for run '{run_id}' not found in the traces data map. Skipping processing of _finalize_trace."
225
+ )
226
+ return
201
227
 
202
- if span_data.trace_id not in self._externally_created_traces_ids:
203
- trace_data = self._created_traces_data_map[run.id]
228
+ # workaround for `.astream()` method usage
229
+ if trace_data.input == {"input": ""}:
230
+ trace_data.input = run_dict["inputs"]
204
231
 
205
- # workaround for `.astream()` method usage
206
- if trace_data.input == {"input": ""}:
207
- trace_data.input = run_dict["inputs"]
232
+ if trace_additional_metadata:
233
+ trace_data.update(metadata=trace_additional_metadata)
208
234
 
209
- if trace_additional_metadata:
210
- trace_data.update(metadata=trace_additional_metadata)
235
+ trace_data.init_end_time().update(output=outputs, error_info=error_info)
236
+ trace_ = self._opik_client.trace(**trace_data.as_parameters)
211
237
 
212
- trace_data.init_end_time().update(output=outputs, error_info=error_info)
213
- trace_ = self._opik_client.trace(**trace_data.as_parameters)
214
-
215
- assert trace_ is not None
216
- self._created_traces.append(trace_)
217
- self._opik_context_storage.pop_trace_data(ensure_id=trace_data.id)
238
+ assert trace_ is not None
239
+ self._created_traces.append(trace_)
240
+ self._opik_context_storage.pop_trace_data(ensure_id=trace_data.id)
218
241
 
219
242
  def _ensure_no_hanging_opik_tracer_spans(self) -> None:
220
243
  root_run_external_parent_span_id = self._root_run_external_parent_span_id.get()
@@ -231,47 +254,71 @@ class OpikTracer(BaseTracer):
231
254
  )
232
255
 
233
256
  def _track_root_run(
234
- self, run_dict: Dict[str, Any]
235
- ) -> Tuple[Optional[trace.TraceData], span.SpanData]:
236
- run_metadata = run_dict["extra"].get("metadata", {})
257
+ self, run_dict: Dict[str, Any], allow_duplicating_root_span: bool
258
+ ) -> TrackRootRunResult:
259
+ run_metadata = _get_run_metadata(run_dict)
237
260
  root_metadata = dict_utils.deepmerge(self._trace_default_metadata, run_metadata)
238
261
  self._update_thread_id_from_metadata(run_dict)
239
262
 
263
+ # Skip creating a span for root runs only when creating a new trace
264
+ # Keep the span when invoked from a tracked function, existing trace or distributed headers
265
+
240
266
  if self._distributed_headers:
241
267
  new_span_data = self._attach_span_to_distributed_headers(
242
268
  run_dict=run_dict,
243
- root_metadata=root_metadata,
269
+ metadata=root_metadata,
270
+ )
271
+ return TrackRootRunResult(
272
+ new_trace_data=None,
273
+ new_span_data=new_span_data,
244
274
  )
245
- return None, new_span_data
246
275
 
247
276
  current_span_data = self._opik_context_storage.top_span_data()
248
- self._root_run_external_parent_span_id.set(
277
+ parent_span_id_when_langgraph_started = (
249
278
  current_span_data.id if current_span_data is not None else None
250
279
  )
280
+ self._root_run_external_parent_span_id.set(
281
+ parent_span_id_when_langgraph_started
282
+ )
251
283
  if current_span_data is not None:
252
- new_span_data = self._attach_span_to_existing_span(
284
+ # When invoked from a tracked function, keep the root span
285
+ # and attach it to the parent span (don't skip it)
286
+ new_span_data = self._attach_span_to_external_span(
253
287
  run_dict=run_dict,
254
288
  current_span_data=current_span_data,
255
289
  root_metadata=root_metadata,
256
290
  )
257
- return None, new_span_data
291
+ return TrackRootRunResult(
292
+ new_trace_data=None,
293
+ new_span_data=new_span_data,
294
+ )
258
295
 
259
296
  current_trace_data = self._opik_context_storage.get_trace_data()
260
297
  if current_trace_data is not None:
261
- new_span_data = self._attach_span_to_existing_trace(
298
+ # When invoked under an existing trace, keep the root span
299
+ # and attach it to the parent trace (don't skip it)
300
+ new_span_data = self._attach_span_to_external_trace(
262
301
  run_dict=run_dict,
263
302
  current_trace_data=current_trace_data,
264
303
  root_metadata=root_metadata,
265
304
  )
266
- return None, new_span_data
305
+ return TrackRootRunResult(
306
+ new_trace_data=None,
307
+ new_span_data=new_span_data,
308
+ )
267
309
 
268
310
  return self._initialize_span_and_trace_from_scratch(
269
- run_dict=run_dict, root_metadata=root_metadata
311
+ run_dict=run_dict,
312
+ root_metadata=root_metadata,
313
+ allow_duplicating_root_span=allow_duplicating_root_span,
270
314
  )
271
315
 
272
316
  def _initialize_span_and_trace_from_scratch(
273
- self, run_dict: Dict[str, Any], root_metadata: Dict[str, Any]
274
- ) -> Tuple[trace.TraceData, span.SpanData]:
317
+ self,
318
+ run_dict: Dict[str, Any],
319
+ root_metadata: Dict[str, Any],
320
+ allow_duplicating_root_span: bool,
321
+ ) -> TrackRootRunResult:
275
322
  trace_data = trace.TraceData(
276
323
  name=run_dict["name"],
277
324
  input=run_dict["inputs"],
@@ -280,6 +327,14 @@ class OpikTracer(BaseTracer):
280
327
  project_name=self._project_name,
281
328
  thread_id=self._thread_id,
282
329
  )
330
+
331
+ # Skip creating a span for LangGraph root runs - children will be attached directly to trace
332
+ if _is_root_run(run_dict) and not allow_duplicating_root_span:
333
+ return TrackRootRunResult(
334
+ new_trace_data=trace_data,
335
+ new_span_data=None,
336
+ )
337
+
283
338
  span_data = span.SpanData(
284
339
  trace_id=trace_data.id,
285
340
  parent_span_id=None,
@@ -290,9 +345,9 @@ class OpikTracer(BaseTracer):
290
345
  tags=self._trace_default_tags,
291
346
  project_name=self._project_name,
292
347
  )
293
- return trace_data, span_data
348
+ return TrackRootRunResult(new_trace_data=trace_data, new_span_data=span_data)
294
349
 
295
- def _attach_span_to_existing_span(
350
+ def _attach_span_to_external_span(
296
351
  self,
297
352
  run_dict: Dict[str, Any],
298
353
  current_span_data: span.SpanData,
@@ -318,7 +373,7 @@ class OpikTracer(BaseTracer):
318
373
 
319
374
  return span_data
320
375
 
321
- def _attach_span_to_existing_trace(
376
+ def _attach_span_to_external_trace(
322
377
  self,
323
378
  run_dict: Dict[str, Any],
324
379
  current_trace_data: trace.TraceData,
@@ -339,6 +394,8 @@ class OpikTracer(BaseTracer):
339
394
  project_name=project_name,
340
395
  type=_get_span_type(run_dict),
341
396
  )
397
+ span_data.update(metadata={"created_from": "langchain"})
398
+
342
399
  if not self._is_opik_trace_created_by_this_tracer(current_trace_data.id):
343
400
  self._externally_created_traces_ids.add(current_trace_data.id)
344
401
  return span_data
@@ -346,7 +403,7 @@ class OpikTracer(BaseTracer):
346
403
  def _attach_span_to_distributed_headers(
347
404
  self,
348
405
  run_dict: Dict[str, Any],
349
- root_metadata: Dict[str, Any],
406
+ metadata: Dict[str, Any],
350
407
  ) -> span.SpanData:
351
408
  if self._distributed_headers is None:
352
409
  raise ValueError("Distributed headers are not set")
@@ -356,7 +413,7 @@ class OpikTracer(BaseTracer):
356
413
  parent_span_id=self._distributed_headers["opik_parent_span_id"],
357
414
  name=run_dict["name"],
358
415
  input=run_dict["inputs"],
359
- metadata=root_metadata,
416
+ metadata=metadata,
360
417
  tags=self._trace_default_tags,
361
418
  project_name=self._project_name,
362
419
  type=_get_span_type(run_dict),
@@ -364,43 +421,106 @@ class OpikTracer(BaseTracer):
364
421
  self._externally_created_traces_ids.add(span_data.trace_id)
365
422
  return span_data
366
423
 
367
- def _process_start_span(self, run: Run) -> None:
424
+ def _process_start_span(self, run: Run, allow_duplicating_root_span: bool) -> None:
368
425
  try:
369
- self._process_start_span_unsafe(run)
426
+ self._process_start_span_unsafe(run, allow_duplicating_root_span)
370
427
  except Exception as e:
371
428
  LOGGER.error("Failed during _process_start_span: %s", e, exc_info=True)
372
429
 
373
- def _process_start_span_unsafe(self, run: Run) -> None:
430
+ def _process_start_span_unsafe(
431
+ self, run: Run, allow_duplicating_root_span: bool
432
+ ) -> None:
374
433
  run_dict: Dict[str, Any] = run.dict()
375
- new_span_data: span.SpanData
376
434
 
377
435
  if not run.parent_run_id:
378
- # This is the first run for the chain.
379
- new_trace_data, new_span_data = self._track_root_run(run_dict)
380
- if new_trace_data is not None:
381
- self._opik_context_storage.set_trace_data(new_trace_data)
382
- if (
383
- self._opik_client.config.log_start_trace_span
384
- and tracing_runtime_config.is_tracing_active()
385
- ):
386
- self._opik_client.trace(**new_trace_data.as_start_parameters)
436
+ self._create_root_trace_and_span(
437
+ run_id=run.id,
438
+ run_dict=run_dict,
439
+ allow_duplicating_root_span=allow_duplicating_root_span,
440
+ )
441
+ return
387
442
 
443
+ # Check if the parent is a skipped LangGraph/LangChain root run.
444
+ # If so, attach children directly to trace.
445
+ # Otherwise, attach to the parent span.
446
+ if run.parent_run_id in self._skipped_langgraph_root_run_ids:
447
+ self._attach_span_to_local_or_distributed_trace(
448
+ run_id=run.id,
449
+ parent_run_id=run.parent_run_id,
450
+ run_dict=run_dict,
451
+ )
452
+ else:
453
+ self._attach_span_to_parent_span(
454
+ run_id=run.id, parent_run_id=run.parent_run_id, run_dict=run_dict
455
+ )
456
+
457
+ def _create_root_trace_and_span(
458
+ self, run_id: UUID, run_dict: Dict[str, Any], allow_duplicating_root_span: bool
459
+ ) -> None:
460
+ """
461
+ Creates a root trace and span for a given run and stores the relevant trace and span
462
+ data in local storage for future reference.
463
+
464
+ The new span is only created if no new trace is created, i.e., when attached to an existing span
465
+ or distributed headers. If a new trace is created, the span is skipped and only the
466
+ trace data is stored in local storage for future reference.
467
+ """
468
+ # This is the first run for the chain.
469
+ root_run_result = self._track_root_run(run_dict, allow_duplicating_root_span)
470
+ if root_run_result.new_trace_data is not None:
471
+ self._opik_context_storage.set_trace_data(root_run_result.new_trace_data)
472
+ if (
473
+ self._opik_client.config.log_start_trace_span
474
+ and tracing_runtime_config.is_tracing_active()
475
+ ):
476
+ self._opik_client.trace(
477
+ **root_run_result.new_trace_data.as_start_parameters
478
+ )
479
+
480
+ # If this is a LangGraph/LangChain root run under fresh trace, skip creating the span
481
+ if root_run_result.new_span_data is None:
482
+ # Mark this run as skipped and store trace data for child runs
483
+ self._skipped_langgraph_root_run_ids.add(run_id)
484
+
485
+ # Store parent span ID if LangGraph was attached to the existing span
486
+ parent_span_id = self._root_run_external_parent_span_id.get()
487
+ self._langgraph_parent_span_ids[run_id] = parent_span_id
488
+
489
+ # Store trace data if we created a new trace but skip span data
490
+ if root_run_result.new_trace_data is not None:
491
+ self._save_span_trace_data_to_local_maps(
492
+ run_id=run_id,
493
+ span_data=None,
494
+ trace_data=root_run_result.new_trace_data,
495
+ )
496
+ else:
388
497
  # save new span and trace data to local maps to be able to retrieve them later
389
498
  self._save_span_trace_data_to_local_maps(
390
- run_id=run.id,
391
- span_data=new_span_data,
392
- trace_data=new_trace_data,
499
+ run_id=run_id,
500
+ span_data=root_run_result.new_span_data,
501
+ trace_data=root_run_result.new_trace_data,
393
502
  )
394
503
 
395
- self._opik_context_storage.add_span_data(new_span_data)
504
+ self._opik_context_storage.add_span_data(root_run_result.new_span_data)
396
505
  if (
397
506
  self._opik_client.config.log_start_trace_span
398
507
  and tracing_runtime_config.is_tracing_active()
399
508
  ):
400
- self._opik_client.span(**new_span_data.as_start_parameters)
401
- return
509
+ self._opik_client.span(
510
+ **root_run_result.new_span_data.as_start_parameters
511
+ )
402
512
 
403
- parent_span_data = self._span_data_map[run.parent_run_id]
513
+ def _attach_span_to_parent_span(
514
+ self, run_id: UUID, parent_run_id: UUID, run_dict: Dict[str, Any]
515
+ ) -> None:
516
+ """
517
+ Attaches child span to a parent span and updates relevant context storage.
518
+
519
+ This method is responsible for creating a new span data object associated with a
520
+ run, linking it to the parent span data, and saving it to local and external maps.
521
+ Additionally, it updates the context storage and logs the span if tracing is active.
522
+ """
523
+ parent_span_data = self._span_data_map[parent_run_id]
404
524
 
405
525
  project_name = helpers.resolve_child_span_project_name(
406
526
  parent_span_data.project_name,
@@ -411,22 +531,22 @@ class OpikTracer(BaseTracer):
411
531
  trace_id=parent_span_data.trace_id,
412
532
  parent_span_id=parent_span_data.id,
413
533
  input=run_dict["inputs"],
414
- metadata=run_dict["extra"],
415
- name=run.name,
534
+ metadata=_get_run_metadata(run_dict),
535
+ name=run_dict["name"],
416
536
  type=_get_span_type(run_dict),
417
537
  project_name=project_name,
418
538
  )
419
539
  new_span_data.update(metadata={"created_from": "langchain"})
420
540
 
421
541
  self._save_span_trace_data_to_local_maps(
422
- run_id=run.id,
542
+ run_id=run_id,
423
543
  span_data=new_span_data,
424
544
  trace_data=None,
425
545
  )
426
546
 
427
547
  if new_span_data.trace_id not in self._externally_created_traces_ids:
428
- self._created_traces_data_map[run.id] = self._created_traces_data_map[
429
- run.parent_run_id
548
+ self._created_traces_data_map[run_id] = self._created_traces_data_map[
549
+ parent_run_id
430
550
  ]
431
551
 
432
552
  self._opik_context_storage.add_span_data(new_span_data)
@@ -436,9 +556,76 @@ class OpikTracer(BaseTracer):
436
556
  ):
437
557
  self._opik_client.span(**new_span_data.as_start_parameters)
438
558
 
559
+ def _attach_span_to_local_or_distributed_trace(
560
+ self, run_id: UUID, parent_run_id: UUID, run_dict: Dict[str, Any]
561
+ ) -> None:
562
+ """
563
+ Attaches child span directly to a trace by checking trace data or distributed
564
+ headers and creates new span data based on the provided run information.
565
+ """
566
+ # Check if we have trace data (new trace) or distributed headers
567
+ if parent_run_id in self._created_traces_data_map:
568
+ # LangGraph created a new trace - attach children directly to trace
569
+ trace_data = self._created_traces_data_map[parent_run_id]
570
+ project_name = helpers.resolve_child_span_project_name(
571
+ trace_data.project_name,
572
+ self._project_name,
573
+ )
574
+
575
+ new_span_data = span.SpanData(
576
+ trace_id=trace_data.id,
577
+ parent_span_id=None, # Direct child of trace
578
+ input=run_dict["inputs"],
579
+ metadata=_get_run_metadata(run_dict),
580
+ name=run_dict["name"],
581
+ type=_get_span_type(run_dict),
582
+ project_name=project_name,
583
+ )
584
+ if new_span_data.trace_id not in self._externally_created_traces_ids:
585
+ self._created_traces_data_map[run_id] = trace_data
586
+
587
+ elif self._distributed_headers:
588
+ # LangGraph with distributed headers - attach to distributed trace
589
+ new_span_data = self._attach_span_to_distributed_headers(
590
+ run_dict=run_dict,
591
+ metadata=_get_run_metadata(run_dict),
592
+ )
593
+ elif (
594
+ current_trace_data := self._opik_context_storage.get_trace_data()
595
+ ) is not None:
596
+ # LangGraph attached to existing trace - attach children directly to trace
597
+ new_span_data = self._attach_span_to_external_trace(
598
+ run_dict=run_dict,
599
+ current_trace_data=current_trace_data,
600
+ root_metadata=_get_run_metadata(run_dict),
601
+ )
602
+ else:
603
+ LOGGER.warning(
604
+ f"Cannot find trace data or distributed headers for LangGraph child run '{run_id}'"
605
+ )
606
+ return
607
+
608
+ new_span_data.update(metadata={"created_from": "langchain"})
609
+ self._save_span_trace_data_to_local_maps(
610
+ run_id=run_id,
611
+ span_data=new_span_data,
612
+ trace_data=None,
613
+ )
614
+
615
+ self._opik_context_storage.add_span_data(new_span_data)
616
+ if (
617
+ self._opik_client.config.log_start_trace_span
618
+ and tracing_runtime_config.is_tracing_active()
619
+ ):
620
+ self._opik_client.span(**new_span_data.as_start_parameters)
621
+
439
622
  def _process_end_span(self, run: Run) -> None:
440
623
  span_data = None
441
624
  try:
625
+ # Skip processing if this is a skipped LangGraph root run
626
+ if run.id in self._skipped_langgraph_root_run_ids:
627
+ return
628
+
442
629
  if run.id not in self._span_data_map:
443
630
  LOGGER.warning(
444
631
  f"Span data for run '{run.id}' not found in the span data map. Skipping processing of end span."
@@ -493,6 +680,10 @@ class OpikTracer(BaseTracer):
493
680
  return self._skip_error_callback(error_str)
494
681
 
495
682
  def _process_end_span_with_error(self, run: Run) -> None:
683
+ # Skip processing if this is a skipped LangGraph root run
684
+ if run.id in self._skipped_langgraph_root_run_ids:
685
+ return
686
+
496
687
  if run.id not in self._span_data_map:
497
688
  LOGGER.warning(
498
689
  f"Span data for run '{run.id}' not found in the span data map. Skipping processing of _process_end_span_with_error."
@@ -531,7 +722,7 @@ class OpikTracer(BaseTracer):
531
722
  def _update_thread_id_from_metadata(self, run_dict: Dict[str, Any]) -> None:
532
723
  if not self._thread_id:
533
724
  # We want to default to any manually set thread_id, so only update if self._thread_id is not already set
534
- thread_id = run_dict["extra"].get("metadata", {}).get("thread_id")
725
+ thread_id = _get_run_metadata(run_dict).get("thread_id")
535
726
 
536
727
  if thread_id:
537
728
  self._thread_id = thread_id
@@ -577,7 +768,7 @@ class OpikTracer(BaseTracer):
577
768
  if self._skip_tracking():
578
769
  return
579
770
 
580
- self._process_start_span(run)
771
+ self._process_start_span(run, allow_duplicating_root_span=True)
581
772
 
582
773
  def on_chat_model_start(
583
774
  self,
@@ -637,7 +828,7 @@ class OpikTracer(BaseTracer):
637
828
  if self._skip_tracking():
638
829
  return
639
830
 
640
- self._process_start_span(run)
831
+ self._process_start_span(run, allow_duplicating_root_span=True)
641
832
 
642
833
  def _on_llm_end(self, run: Run) -> None:
643
834
  """Process the LLM Run."""
@@ -658,7 +849,7 @@ class OpikTracer(BaseTracer):
658
849
  if self._skip_tracking():
659
850
  return
660
851
 
661
- self._process_start_span(run)
852
+ self._process_start_span(run, allow_duplicating_root_span=False)
662
853
 
663
854
  def _on_chain_end(self, run: Run) -> None:
664
855
  """Process the Chain Run."""
@@ -679,7 +870,7 @@ class OpikTracer(BaseTracer):
679
870
  if self._skip_tracking():
680
871
  return
681
872
 
682
- self._process_start_span(run)
873
+ self._process_start_span(run, allow_duplicating_root_span=True)
683
874
 
684
875
  def _on_tool_end(self, run: Run) -> None:
685
876
  """Process the Tool Run."""