diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py index 145bd70dbc..045ec44e4e 100644 --- a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py @@ -64,7 +64,9 @@ class LangSmithDataTrace(BaseTraceInstance): self.generate_name_trace(trace_info) def workflow_trace(self, trace_info: WorkflowTraceInfo): - trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id + # trace_id must equal the root run's run_id (LangSmith protocol); external trace_id + # cannot be used here as it would cause HTTP 400. + trace_id = trace_info.message_id or trace_info.workflow_run_id if trace_info.start_time is None: trace_info.start_time = datetime.now() message_dotted_order = ( @@ -77,6 +79,8 @@ class LangSmithDataTrace(BaseTraceInstance): ) metadata = trace_info.metadata metadata["workflow_app_log_id"] = trace_info.workflow_app_log_id + if trace_info.trace_id: + metadata["external_trace_id"] = trace_info.trace_id if trace_info.message_id: message_run = LangSmithRunModel( diff --git a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py index ee59acb17e..edc4aafd87 100644 --- a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py @@ -208,13 +208,17 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): assert call_args[0].id == "msg-1" assert call_args[0].name == TraceTaskName.MESSAGE_TRACE + # trace_id must equal root run's id (message_id), not the external trace_id "trace-1" + assert call_args[0].trace_id == "msg-1" assert call_args[1].id == "run-1" assert call_args[1].name == TraceTaskName.WORKFLOW_TRACE assert call_args[1].parent_run_id == "msg-1" + assert call_args[1].trace_id == "msg-1" assert call_args[2].id == "node-llm" assert call_args[2].run_type == LangSmithRunType.llm + assert call_args[2].trace_id == "msg-1" assert call_args[3].id == "node-other" assert call_args[3].run_type == LangSmithRunType.tool @@ -604,3 +608,83 @@ def test_get_project_url_error(trace_instance): trace_instance.langsmith_client.get_run_url.side_effect = Exception("error") with pytest.raises(ValueError, match="LangSmith get run url failed: error"): trace_instance.get_project_url() + + +def _make_workflow_trace_info( + *, message_id: str | None, workflow_run_id: str, trace_id: str | None +) -> WorkflowTraceInfo: + workflow_data = MagicMock() + workflow_data.created_at = _dt() + workflow_data.finished_at = _dt() + timedelta(seconds=1) + return WorkflowTraceInfo( + tenant_id="tenant-1", + workflow_id="wf-1", + workflow_run_id=workflow_run_id, + workflow_run_inputs={}, + workflow_run_outputs={}, + workflow_run_status="succeeded", + workflow_run_version="1.0", + workflow_run_elapsed_time=1.0, + total_tokens=0, + file_list=[], + query="q", + message_id=message_id, + conversation_id="conv-1" if message_id else None, + start_time=_dt(), + end_time=_dt() + timedelta(seconds=1), + trace_id=trace_id, + metadata={"app_id": "app-1"}, + workflow_app_log_id=None, + error=None, + workflow_data=workflow_data, + ) + + +def _patch_workflow_trace_deps(monkeypatch, trace_instance): + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + factory = MagicMock() + factory.create_workflow_node_execution_repository.return_value = repo + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", factory) + monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) + trace_instance.add_run = MagicMock() + + +def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch): + """Chatflow with external trace_id: LangSmith trace_id must be message_id, not external.""" + trace_info = _make_workflow_trace_info( + message_id="msg-abc", + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # message run (root) and workflow run (child) must both use message_id as trace_id + assert calls[0].id == "msg-abc" + assert calls[0].trace_id == "msg-abc" + assert calls[1].id == "run-xyz" + assert calls[1].trace_id == "msg-abc" + # external_trace_id preserved in metadata + assert trace_info.metadata.get("external_trace_id") == "external-999" + + +def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch): + """Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id.""" + trace_info = _make_workflow_trace_info( + message_id=None, + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # workflow run is the root; trace_id must equal its run_id + assert calls[0].id == "run-xyz" + assert calls[0].trace_id == "run-xyz"