opik_trace.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. import logging
  2. import os
  3. import uuid
  4. from datetime import datetime, timedelta
  5. from typing import Optional, cast
  6. from opik import Opik, Trace
  7. from opik.id_helpers import uuid4_to_uuid7
  8. from sqlalchemy.orm import sessionmaker
  9. from core.ops.base_trace_instance import BaseTraceInstance
  10. from core.ops.entities.config_entity import OpikConfig
  11. from core.ops.entities.trace_entity import (
  12. BaseTraceInfo,
  13. DatasetRetrievalTraceInfo,
  14. GenerateNameTraceInfo,
  15. MessageTraceInfo,
  16. ModerationTraceInfo,
  17. SuggestedQuestionTraceInfo,
  18. ToolTraceInfo,
  19. TraceTaskName,
  20. WorkflowTraceInfo,
  21. )
  22. from core.repositories import DifyCoreRepositoryFactory
  23. from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey
  24. from core.workflow.nodes.enums import NodeType
  25. from extensions.ext_database import db
  26. from models import EndUser, MessageFile, WorkflowNodeExecutionTriggeredFrom
  27. logger = logging.getLogger(__name__)
  28. def wrap_dict(key_name, data):
  29. """Make sure that the input data is a dict"""
  30. if not isinstance(data, dict):
  31. return {key_name: data}
  32. return data
  33. def wrap_metadata(metadata, **kwargs):
  34. """Add common metatada to all Traces and Spans"""
  35. metadata["created_from"] = "dify"
  36. metadata.update(kwargs)
  37. return metadata
  38. def prepare_opik_uuid(user_datetime: Optional[datetime], user_uuid: Optional[str]):
  39. """Opik needs UUIDv7 while Dify uses UUIDv4 for identifier of most
  40. messages and objects. The type-hints of BaseTraceInfo indicates that
  41. objects start_time and message_id could be null which means we cannot map
  42. it to a UUIDv7. Given that we have no way to identify that object
  43. uniquely, generate a new random one UUIDv7 in that case.
  44. """
  45. if user_datetime is None:
  46. user_datetime = datetime.now()
  47. if user_uuid is None:
  48. user_uuid = str(uuid.uuid4())
  49. return uuid4_to_uuid7(user_datetime, user_uuid)
  50. class OpikDataTrace(BaseTraceInstance):
  51. def __init__(
  52. self,
  53. opik_config: OpikConfig,
  54. ):
  55. super().__init__(opik_config)
  56. self.opik_client = Opik(
  57. project_name=opik_config.project,
  58. workspace=opik_config.workspace,
  59. host=opik_config.url,
  60. api_key=opik_config.api_key,
  61. )
  62. self.project = opik_config.project
  63. self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001")
  64. def trace(self, trace_info: BaseTraceInfo):
  65. if isinstance(trace_info, WorkflowTraceInfo):
  66. self.workflow_trace(trace_info)
  67. if isinstance(trace_info, MessageTraceInfo):
  68. self.message_trace(trace_info)
  69. if isinstance(trace_info, ModerationTraceInfo):
  70. self.moderation_trace(trace_info)
  71. if isinstance(trace_info, SuggestedQuestionTraceInfo):
  72. self.suggested_question_trace(trace_info)
  73. if isinstance(trace_info, DatasetRetrievalTraceInfo):
  74. self.dataset_retrieval_trace(trace_info)
  75. if isinstance(trace_info, ToolTraceInfo):
  76. self.tool_trace(trace_info)
  77. if isinstance(trace_info, GenerateNameTraceInfo):
  78. self.generate_name_trace(trace_info)
  79. def workflow_trace(self, trace_info: WorkflowTraceInfo):
  80. dify_trace_id = trace_info.trace_id or trace_info.workflow_run_id
  81. opik_trace_id = prepare_opik_uuid(trace_info.start_time, dify_trace_id)
  82. workflow_metadata = wrap_metadata(
  83. trace_info.metadata, message_id=trace_info.message_id, workflow_app_log_id=trace_info.workflow_app_log_id
  84. )
  85. root_span_id = None
  86. if trace_info.message_id:
  87. dify_trace_id = trace_info.trace_id or trace_info.message_id
  88. opik_trace_id = prepare_opik_uuid(trace_info.start_time, dify_trace_id)
  89. trace_data = {
  90. "id": opik_trace_id,
  91. "name": TraceTaskName.MESSAGE_TRACE.value,
  92. "start_time": trace_info.start_time,
  93. "end_time": trace_info.end_time,
  94. "metadata": workflow_metadata,
  95. "input": wrap_dict("input", trace_info.workflow_run_inputs),
  96. "output": wrap_dict("output", trace_info.workflow_run_outputs),
  97. "thread_id": trace_info.conversation_id,
  98. "tags": ["message", "workflow"],
  99. "project_name": self.project,
  100. }
  101. self.add_trace(trace_data)
  102. root_span_id = prepare_opik_uuid(trace_info.start_time, trace_info.workflow_run_id)
  103. span_data = {
  104. "id": root_span_id,
  105. "parent_span_id": None,
  106. "trace_id": opik_trace_id,
  107. "name": TraceTaskName.WORKFLOW_TRACE.value,
  108. "input": wrap_dict("input", trace_info.workflow_run_inputs),
  109. "output": wrap_dict("output", trace_info.workflow_run_outputs),
  110. "start_time": trace_info.start_time,
  111. "end_time": trace_info.end_time,
  112. "metadata": workflow_metadata,
  113. "tags": ["workflow"],
  114. "project_name": self.project,
  115. }
  116. self.add_span(span_data)
  117. else:
  118. trace_data = {
  119. "id": opik_trace_id,
  120. "name": TraceTaskName.MESSAGE_TRACE.value,
  121. "start_time": trace_info.start_time,
  122. "end_time": trace_info.end_time,
  123. "metadata": workflow_metadata,
  124. "input": wrap_dict("input", trace_info.workflow_run_inputs),
  125. "output": wrap_dict("output", trace_info.workflow_run_outputs),
  126. "thread_id": trace_info.conversation_id,
  127. "tags": ["workflow"],
  128. "project_name": self.project,
  129. }
  130. self.add_trace(trace_data)
  131. # through workflow_run_id get all_nodes_execution using repository
  132. session_factory = sessionmaker(bind=db.engine)
  133. # Find the app's creator account
  134. app_id = trace_info.metadata.get("app_id")
  135. if not app_id:
  136. raise ValueError("No app_id found in trace_info metadata")
  137. service_account = self.get_service_account_with_tenant(app_id)
  138. workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
  139. session_factory=session_factory,
  140. user=service_account,
  141. app_id=app_id,
  142. triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
  143. )
  144. # Get all executions for this workflow run
  145. workflow_node_executions = workflow_node_execution_repository.get_by_workflow_run(
  146. workflow_run_id=trace_info.workflow_run_id
  147. )
  148. for node_execution in workflow_node_executions:
  149. node_execution_id = node_execution.id
  150. tenant_id = trace_info.tenant_id # Use from trace_info instead
  151. app_id = trace_info.metadata.get("app_id") # Use from trace_info instead
  152. node_name = node_execution.title
  153. node_type = node_execution.node_type
  154. status = node_execution.status
  155. if node_type == NodeType.LLM:
  156. inputs = node_execution.process_data.get("prompts", {}) if node_execution.process_data else {}
  157. else:
  158. inputs = node_execution.inputs if node_execution.inputs else {}
  159. outputs = node_execution.outputs if node_execution.outputs else {}
  160. created_at = node_execution.created_at or datetime.now()
  161. elapsed_time = node_execution.elapsed_time
  162. finished_at = created_at + timedelta(seconds=elapsed_time)
  163. execution_metadata = node_execution.metadata if node_execution.metadata else {}
  164. metadata = {str(k): v for k, v in execution_metadata.items()}
  165. metadata.update(
  166. {
  167. "workflow_run_id": trace_info.workflow_run_id,
  168. "node_execution_id": node_execution_id,
  169. "tenant_id": tenant_id,
  170. "app_id": app_id,
  171. "app_name": node_name,
  172. "node_type": node_type,
  173. "status": status,
  174. }
  175. )
  176. process_data = node_execution.process_data if node_execution.process_data else {}
  177. provider = None
  178. model = None
  179. total_tokens = 0
  180. completion_tokens = 0
  181. prompt_tokens = 0
  182. if process_data and process_data.get("model_mode") == "chat":
  183. run_type = "llm"
  184. provider = process_data.get("model_provider", None)
  185. model = process_data.get("model_name", "")
  186. metadata.update(
  187. {
  188. "ls_provider": provider,
  189. "ls_model_name": model,
  190. }
  191. )
  192. try:
  193. usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {})
  194. total_tokens = usage_data.get("total_tokens", 0)
  195. prompt_tokens = usage_data.get("prompt_tokens", 0)
  196. completion_tokens = usage_data.get("completion_tokens", 0)
  197. except Exception:
  198. logger.error("Failed to extract usage", exc_info=True)
  199. else:
  200. run_type = "tool"
  201. parent_span_id = trace_info.workflow_app_log_id or trace_info.workflow_run_id
  202. if not total_tokens:
  203. total_tokens = execution_metadata.get(WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS) or 0
  204. span_data = {
  205. "trace_id": opik_trace_id,
  206. "id": prepare_opik_uuid(created_at, node_execution_id),
  207. "parent_span_id": prepare_opik_uuid(trace_info.start_time, parent_span_id),
  208. "name": node_name,
  209. "type": run_type,
  210. "start_time": created_at,
  211. "end_time": finished_at,
  212. "metadata": wrap_metadata(metadata),
  213. "input": wrap_dict("input", inputs),
  214. "output": wrap_dict("output", outputs),
  215. "tags": ["node_execution"],
  216. "project_name": self.project,
  217. "usage": {
  218. "total_tokens": total_tokens,
  219. "completion_tokens": completion_tokens,
  220. "prompt_tokens": prompt_tokens,
  221. },
  222. "model": model,
  223. "provider": provider,
  224. }
  225. self.add_span(span_data)
  226. def message_trace(self, trace_info: MessageTraceInfo):
  227. # get message file data
  228. file_list = cast(list[str], trace_info.file_list) or []
  229. message_file_data: Optional[MessageFile] = trace_info.message_file_data
  230. if message_file_data is not None:
  231. file_url = f"{self.file_base_url}/{message_file_data.url}" if message_file_data else ""
  232. file_list.append(file_url)
  233. message_data = trace_info.message_data
  234. if message_data is None:
  235. return
  236. metadata = trace_info.metadata
  237. dify_trace_id = trace_info.trace_id or trace_info.message_id
  238. user_id = message_data.from_account_id
  239. metadata["user_id"] = user_id
  240. metadata["file_list"] = file_list
  241. if message_data.from_end_user_id:
  242. end_user_data: Optional[EndUser] = (
  243. db.session.query(EndUser).where(EndUser.id == message_data.from_end_user_id).first()
  244. )
  245. if end_user_data is not None:
  246. end_user_id = end_user_data.session_id
  247. metadata["end_user_id"] = end_user_id
  248. trace_data = {
  249. "id": prepare_opik_uuid(trace_info.start_time, dify_trace_id),
  250. "name": TraceTaskName.MESSAGE_TRACE.value,
  251. "start_time": trace_info.start_time,
  252. "end_time": trace_info.end_time,
  253. "metadata": wrap_metadata(metadata),
  254. "input": trace_info.inputs,
  255. "output": message_data.answer,
  256. "thread_id": message_data.conversation_id,
  257. "tags": ["message", str(trace_info.conversation_mode)],
  258. "project_name": self.project,
  259. }
  260. trace = self.add_trace(trace_data)
  261. span_data = {
  262. "trace_id": trace.id,
  263. "name": "llm",
  264. "type": "llm",
  265. "start_time": trace_info.start_time,
  266. "end_time": trace_info.end_time,
  267. "metadata": wrap_metadata(metadata),
  268. "input": {"input": trace_info.inputs},
  269. "output": {"output": message_data.answer},
  270. "tags": ["llm", str(trace_info.conversation_mode)],
  271. "usage": {
  272. "completion_tokens": trace_info.answer_tokens,
  273. "prompt_tokens": trace_info.message_tokens,
  274. "total_tokens": trace_info.total_tokens,
  275. },
  276. "project_name": self.project,
  277. }
  278. self.add_span(span_data)
  279. def moderation_trace(self, trace_info: ModerationTraceInfo):
  280. if trace_info.message_data is None:
  281. return
  282. start_time = trace_info.start_time or trace_info.message_data.created_at
  283. span_data = {
  284. "trace_id": prepare_opik_uuid(start_time, trace_info.trace_id or trace_info.message_id),
  285. "name": TraceTaskName.MODERATION_TRACE.value,
  286. "type": "tool",
  287. "start_time": start_time,
  288. "end_time": trace_info.end_time or trace_info.message_data.updated_at,
  289. "metadata": wrap_metadata(trace_info.metadata),
  290. "input": wrap_dict("input", trace_info.inputs),
  291. "output": {
  292. "action": trace_info.action,
  293. "flagged": trace_info.flagged,
  294. "preset_response": trace_info.preset_response,
  295. "inputs": trace_info.inputs,
  296. },
  297. "tags": ["moderation"],
  298. }
  299. self.add_span(span_data)
  300. def suggested_question_trace(self, trace_info: SuggestedQuestionTraceInfo):
  301. message_data = trace_info.message_data
  302. if message_data is None:
  303. return
  304. start_time = trace_info.start_time or message_data.created_at
  305. span_data = {
  306. "trace_id": prepare_opik_uuid(start_time, trace_info.trace_id or trace_info.message_id),
  307. "name": TraceTaskName.SUGGESTED_QUESTION_TRACE.value,
  308. "type": "tool",
  309. "start_time": start_time,
  310. "end_time": trace_info.end_time or message_data.updated_at,
  311. "metadata": wrap_metadata(trace_info.metadata),
  312. "input": wrap_dict("input", trace_info.inputs),
  313. "output": wrap_dict("output", trace_info.suggested_question),
  314. "tags": ["suggested_question"],
  315. }
  316. self.add_span(span_data)
  317. def dataset_retrieval_trace(self, trace_info: DatasetRetrievalTraceInfo):
  318. if trace_info.message_data is None:
  319. return
  320. start_time = trace_info.start_time or trace_info.message_data.created_at
  321. span_data = {
  322. "trace_id": prepare_opik_uuid(start_time, trace_info.trace_id or trace_info.message_id),
  323. "name": TraceTaskName.DATASET_RETRIEVAL_TRACE.value,
  324. "type": "tool",
  325. "start_time": start_time,
  326. "end_time": trace_info.end_time or trace_info.message_data.updated_at,
  327. "metadata": wrap_metadata(trace_info.metadata),
  328. "input": wrap_dict("input", trace_info.inputs),
  329. "output": {"documents": trace_info.documents},
  330. "tags": ["dataset_retrieval"],
  331. }
  332. self.add_span(span_data)
  333. def tool_trace(self, trace_info: ToolTraceInfo):
  334. span_data = {
  335. "trace_id": prepare_opik_uuid(trace_info.start_time, trace_info.trace_id or trace_info.message_id),
  336. "name": trace_info.tool_name,
  337. "type": "tool",
  338. "start_time": trace_info.start_time,
  339. "end_time": trace_info.end_time,
  340. "metadata": wrap_metadata(trace_info.metadata),
  341. "input": wrap_dict("input", trace_info.tool_inputs),
  342. "output": wrap_dict("output", trace_info.tool_outputs),
  343. "tags": ["tool", trace_info.tool_name],
  344. }
  345. self.add_span(span_data)
  346. def generate_name_trace(self, trace_info: GenerateNameTraceInfo):
  347. trace_data = {
  348. "id": prepare_opik_uuid(trace_info.start_time, trace_info.trace_id or trace_info.message_id),
  349. "name": TraceTaskName.GENERATE_NAME_TRACE.value,
  350. "start_time": trace_info.start_time,
  351. "end_time": trace_info.end_time,
  352. "metadata": wrap_metadata(trace_info.metadata),
  353. "input": trace_info.inputs,
  354. "output": trace_info.outputs,
  355. "thread_id": trace_info.conversation_id,
  356. "tags": ["generate_name"],
  357. "project_name": self.project,
  358. }
  359. trace = self.add_trace(trace_data)
  360. span_data = {
  361. "trace_id": trace.id,
  362. "name": TraceTaskName.GENERATE_NAME_TRACE.value,
  363. "start_time": trace_info.start_time,
  364. "end_time": trace_info.end_time,
  365. "metadata": wrap_metadata(trace_info.metadata),
  366. "input": wrap_dict("input", trace_info.inputs),
  367. "output": wrap_dict("output", trace_info.outputs),
  368. "tags": ["generate_name"],
  369. }
  370. self.add_span(span_data)
  371. def add_trace(self, opik_trace_data: dict) -> Trace:
  372. try:
  373. trace = self.opik_client.trace(**opik_trace_data)
  374. logger.debug("Opik Trace created successfully")
  375. return trace
  376. except Exception as e:
  377. raise ValueError(f"Opik Failed to create trace: {str(e)}")
  378. def add_span(self, opik_span_data: dict):
  379. try:
  380. self.opik_client.span(**opik_span_data)
  381. logger.debug("Opik Span created successfully")
  382. except Exception as e:
  383. raise ValueError(f"Opik Failed to create span: {str(e)}")
  384. def api_check(self):
  385. try:
  386. self.opik_client.auth_check()
  387. return True
  388. except Exception as e:
  389. logger.info("Opik API check failed: %s", str(e), exc_info=True)
  390. raise ValueError(f"Opik API check failed: {str(e)}")
  391. def get_project_url(self):
  392. try:
  393. return self.opik_client.get_project_url(project_name=self.project)
  394. except Exception as e:
  395. logger.info("Opik get run url failed: %s", str(e), exc_info=True)
  396. raise ValueError(f"Opik get run url failed: {str(e)}")