feat: support custom trace session id for Phoenix tracing (#37056)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Blackoutta 2026-06-04 16:42:03 +08:00 committed by GitHub
parent f9320b2c91
commit c8abb11bf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1214 additions and 35 deletions

View File

@ -28,7 +28,7 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id
from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.helper import UUIDStrOrEmpty
@ -56,6 +56,7 @@ class CompletionRequestPayload(BaseModel):
files: list[dict[str, Any]] | None = None
response_mode: Literal["blocking", "streaming"] | None = None
retriever_from: str = Field(default="dev")
trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping")
class ChatRequestPayload(BaseModel):
@ -67,6 +68,7 @@ class ChatRequestPayload(BaseModel):
retriever_from: str = Field(default="dev")
auto_generate_name: bool = Field(default=True, description="Auto generate conversation name")
workflow_id: str | None = Field(default=None, description="Workflow ID for advanced chat")
trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping")
@field_validator("conversation_id", mode="before")
@classmethod
@ -112,9 +114,14 @@ class CompletionApi(Resource):
if app_model.mode != AppMode.COMPLETION:
raise AppUnavailableError()
payload = CompletionRequestPayload.model_validate(service_api_ns.payload or {})
payload = CompletionRequestPayload.model_validate(
omit_trace_session_id_from_payload(service_api_ns.payload) or {}
)
external_trace_id = get_external_trace_id(request)
args = payload.model_dump(exclude_none=True)
trace_session_id = get_trace_session_id(request)
if trace_session_id:
args["trace_session_id"] = trace_session_id
if external_trace_id:
args["external_trace_id"] = external_trace_id
@ -209,10 +216,13 @@ class ChatApi(Resource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
payload = ChatRequestPayload.model_validate(service_api_ns.payload or {})
payload = ChatRequestPayload.model_validate(omit_trace_session_id_from_payload(service_api_ns.payload) or {})
external_trace_id = get_external_trace_id(request)
args = payload.model_dump(exclude_none=True)
trace_session_id = get_trace_session_id(request)
if trace_session_id:
args["trace_session_id"] = trace_session_id
if external_trace_id:
args["external_trace_id"] = external_trace_id

View File

@ -30,7 +30,7 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id
from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
@ -54,6 +54,7 @@ logger = logging.getLogger(__name__)
class WorkflowRunPayload(WorkflowRunPayloadBase):
response_mode: Literal["blocking", "streaming"] | None = None
trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping")
class WorkflowLogQuery(BaseModel):
@ -272,8 +273,11 @@ class WorkflowRunApi(Resource):
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
payload = WorkflowRunPayload.model_validate(service_api_ns.payload or {})
payload = WorkflowRunPayload.model_validate(omit_trace_session_id_from_payload(service_api_ns.payload) or {})
args = payload.model_dump(exclude_none=True)
trace_session_id = get_trace_session_id(request)
if trace_session_id:
args["trace_session_id"] = trace_session_id
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
@ -328,8 +332,11 @@ class WorkflowRunByIdApi(Resource):
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
payload = WorkflowRunPayload.model_validate(service_api_ns.payload or {})
payload = WorkflowRunPayload.model_validate(omit_trace_session_id_from_payload(service_api_ns.payload) or {})
args = payload.model_dump(exclude_none=True)
trace_session_id = get_trace_session_id(request)
if trace_session_id:
args["trace_session_id"] = trace_session_id
# Add workflow_id to args for AppGenerateService
args["workflow_id"] = workflow_id

View File

@ -40,7 +40,7 @@ from core.app.entities.task_entities import (
ChatbotAppStreamResponse,
)
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer
from core.helper.trace_id_helper import extract_external_trace_id_from_args
from core.helper.trace_id_helper import extract_external_trace_id_from_args, extract_trace_session_id_from_args
from core.ops.ops_trace_manager import TraceQueueManager
from core.prompt.utils.get_thread_messages_length import get_thread_messages_length
from core.repositories import DifyCoreRepositoryFactory
@ -64,6 +64,12 @@ from services.workflow_draft_variable_service import (
logger = logging.getLogger(__name__)
def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) -> dict[str, str]:
if isinstance(args, Mapping):
return extract_trace_session_id_from_args(args)
return extract_trace_session_id_from_args({"trace_session_id": getattr(args, "trace_session_id", None)})
class AdvancedChatAppGenerator(MessageBasedAppGenerator):
_dialogue_count: int
@ -140,6 +146,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
extras = {
"auto_generate_conversation_name": args.get("auto_generate_name", False),
**extract_external_trace_id_from_args(args),
**extract_trace_session_id_from_args(args),
}
# get conversation
@ -331,7 +338,10 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
user_id=user.id,
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
extras={
"auto_generate_conversation_name": False,
**_extract_trace_session_id_from_debug_args(args),
},
single_iteration_run=AdvancedChatAppGenerateEntity.SingleIterationRunEntity(
node_id=node_id, inputs=args["inputs"]
),
@ -417,7 +427,10 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
user_id=user.id,
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
extras={
"auto_generate_conversation_name": False,
**_extract_trace_session_id_from_debug_args(args),
},
single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args.inputs),
)
contexts.plugin_tool_providers.set({})

View File

@ -131,6 +131,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
user_id=self.application_generate_entity.user_id,
invoke_from=invoke_from,
user_from=user_from,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
# Handle single iteration or single loop run
@ -139,6 +140,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
user_id=self.application_generate_entity.user_id,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
else:
inputs = self.application_generate_entity.inputs
@ -199,6 +201,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
user_from=user_from,
invoke_from=invoke_from,
root_node_id=root_node_id,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
db.session.close()

View File

@ -113,7 +113,9 @@ class AgentAppGenerator(MessageBasedAppGenerator):
user_id=user.id,
stream=streaming,
invoke_from=invoke_from,
extras={"auto_generate_conversation_name": args.get("auto_generate_name", True)},
extras={
"auto_generate_conversation_name": args.get("auto_generate_name", True),
},
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,

View File

@ -20,6 +20,7 @@ from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity, InvokeFrom
from core.helper.trace_id_helper import extract_trace_session_id_from_args
from core.ops.ops_trace_manager import TraceQueueManager
from extensions.ext_database import db
from factories import file_factory
@ -96,7 +97,10 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
query = query.replace("\x00", "")
inputs = args["inputs"]
extras = {"auto_generate_conversation_name": args.get("auto_generate_name", True)}
extras = {
"auto_generate_conversation_name": args.get("auto_generate_name", True),
**extract_trace_session_id_from_args(args),
}
# get conversation
conversation = None

View File

@ -20,6 +20,7 @@ from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, InvokeFrom
from core.helper.trace_id_helper import extract_trace_session_id_from_args
from core.ops.ops_trace_manager import TraceQueueManager
from extensions.ext_database import db
from factories import file_factory
@ -89,7 +90,10 @@ class ChatAppGenerator(MessageBasedAppGenerator):
query = query.replace("\x00", "")
inputs = args["inputs"]
extras = {"auto_generate_conversation_name": args.get("auto_generate_name", True)}
extras = {
"auto_generate_conversation_name": args.get("auto_generate_name", True),
**extract_trace_session_id_from_args(args),
}
# get conversation
conversation = None

View File

@ -20,6 +20,7 @@ from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import CompletionAppGenerateEntity, InvokeFrom
from core.helper.trace_id_helper import extract_trace_session_id_from_args
from core.ops.ops_trace_manager import TraceQueueManager
from extensions.ext_database import db
from factories import file_factory
@ -148,7 +149,9 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
user_id=user.id,
stream=streaming,
invoke_from=invoke_from,
extras={},
extras={
**extract_trace_session_id_from_args(args),
},
trace_manager=trace_manager,
)

View File

@ -32,7 +32,11 @@ from core.app.entities.task_entities import (
)
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer
from core.db.session_factory import session_factory
from core.helper.trace_id_helper import extract_external_trace_id_from_args, extract_parent_trace_context_from_args
from core.helper.trace_id_helper import (
extract_external_trace_id_from_args,
extract_parent_trace_context_from_args,
extract_trace_session_id_from_args,
)
from core.ops.ops_trace_manager import TraceQueueManager
from core.repositories import DifyCoreRepositoryFactory
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
@ -57,6 +61,12 @@ SKIP_PREPARE_USER_INPUTS_KEY = "_skip_prepare_user_inputs"
logger = logging.getLogger(__name__)
def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) -> dict[str, str]:
if isinstance(args, Mapping):
return extract_trace_session_id_from_args(args)
return extract_trace_session_id_from_args({"trace_session_id": getattr(args, "trace_session_id", None)})
class WorkflowAppGenerator(BaseAppGenerator):
@staticmethod
def _should_prepare_user_inputs(args: Mapping[str, Any]) -> bool:
@ -167,6 +177,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
extras = {
**extract_external_trace_id_from_args(args),
**extract_parent_trace_context_from_args(args),
**extract_trace_session_id_from_args(args),
}
workflow_run_id = str(workflow_run_id or uuid.uuid4())
# FIXME (Yeuoly): we need to remove the SKIP_PREPARE_USER_INPUTS_KEY from the args
@ -410,7 +421,10 @@ class WorkflowAppGenerator(BaseAppGenerator):
user_id=user.id,
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
extras={
"auto_generate_conversation_name": False,
**_extract_trace_session_id_from_debug_args(args),
},
single_iteration_run=WorkflowAppGenerateEntity.SingleIterationRunEntity(
node_id=node_id, inputs=args["inputs"]
),
@ -496,7 +510,10 @@ class WorkflowAppGenerator(BaseAppGenerator):
user_id=user.id,
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
extras={
"auto_generate_conversation_name": False,
**_extract_trace_session_id_from_debug_args(args),
},
single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args.inputs or {}),
workflow_execution_id=str(uuid.uuid4()),
)

View File

@ -87,6 +87,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
user_from=user_from,
invoke_from=invoke_from,
root_node_id=self._root_node_id,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution(
@ -94,6 +95,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
user_id=self.application_generate_entity.user_id,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
else:
inputs = self.application_generate_entity.inputs
@ -128,6 +130,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
user_from=user_from,
invoke_from=invoke_from,
root_node_id=root_node_id,
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
# RUN WORKFLOW

View File

@ -118,6 +118,7 @@ class WorkflowBasedAppRunner:
tenant_id: str = "",
user_id: str = "",
root_node_id: str | None = None,
trace_session_id: str | None = None,
) -> Graph:
"""
Init graph
@ -138,6 +139,7 @@ class WorkflowBasedAppRunner:
user_id=user_id,
user_from=user_from,
invoke_from=invoke_from,
trace_session_id=trace_session_id,
)
graph_init_context = DifyGraphInitContext(
workflow_id=workflow_id,
@ -171,6 +173,7 @@ class WorkflowBasedAppRunner:
single_loop_run: Any | None = None,
*,
user_id: str,
trace_session_id: str | None = None,
) -> tuple[Graph, VariablePool, GraphRuntimeState]:
"""
Prepare graph, variable pool, and runtime state for single node execution
@ -208,6 +211,7 @@ class WorkflowBasedAppRunner:
node_type_filter_key="iteration_id",
node_type_label="iteration",
user_id=user_id,
trace_session_id=trace_session_id,
)
elif single_loop_run:
graph, variable_pool = self._get_graph_and_variable_pool_for_single_node_run(
@ -218,6 +222,7 @@ class WorkflowBasedAppRunner:
node_type_filter_key="loop_id",
node_type_label="loop",
user_id=user_id,
trace_session_id=trace_session_id,
)
else:
raise ValueError("Neither single_iteration_run nor single_loop_run is specified")
@ -236,6 +241,7 @@ class WorkflowBasedAppRunner:
node_type_label: str = "node", # 'iteration' or 'loop' for error messages
*,
user_id: str = "",
trace_session_id: str | None = None,
) -> tuple[Graph, VariablePool]:
"""
Get graph and variable pool for single node execution (iteration or loop).
@ -301,6 +307,7 @@ class WorkflowBasedAppRunner:
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
trace_session_id=trace_session_id,
)
graph_init_context = DifyGraphInitContext(
workflow_id=workflow.id,

View File

@ -54,6 +54,7 @@ class DifyRunContext(BaseModel):
user_id: str
user_from: UserFrom
invoke_from: InvokeFrom
trace_session_id: str | None = None
def build_dify_run_context(
@ -63,6 +64,7 @@ def build_dify_run_context(
user_id: str,
user_from: UserFrom,
invoke_from: InvokeFrom,
trace_session_id: str | None = None,
extra_context: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""
@ -78,6 +80,7 @@ def build_dify_run_context(
user_id=user_id,
user_from=user_from,
invoke_from=invoke_from,
trace_session_id=trace_session_id,
)
return run_context

View File

@ -413,7 +413,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
if trace_manager:
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.MESSAGE_TRACE, conversation_id=self._conversation_id, message_id=self._message_id
TraceTaskName.MESSAGE_TRACE,
conversation_id=self._conversation_id,
message_id=self._message_id,
trace_session_id=self._application_generate_entity.extras.get("trace_session_id"),
)
)

View File

@ -417,10 +417,12 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
conversation_id = self._system_variables().get(SystemVariableKey.CONVERSATION_ID.value)
external_trace_id = None
trace_session_id = None
parent_trace_context = None
if isinstance(self._application_generate_entity, (WorkflowAppGenerateEntity, AdvancedChatAppGenerateEntity)):
extras = self._application_generate_entity.extras
external_trace_id = extras.get("external_trace_id")
trace_session_id = extras.get("trace_session_id")
parent_trace_context = extras.get("parent_trace_context")
if isinstance(parent_trace_context, ParentTraceContext):
parent_trace_context = parent_trace_context.model_dump(exclude_none=True)
@ -431,6 +433,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
conversation_id=conversation_id,
user_id=self._trace_manager.user_id,
external_trace_id=external_trace_id,
trace_session_id=trace_session_id,
parent_trace_context=parent_trace_context,
)
self._trace_manager.add_trace_task(trace_task)

View File

@ -4,6 +4,7 @@ from collections.abc import Mapping
from typing import Any
from pydantic import BaseModel, ConfigDict, StrictStr, ValidationError
from werkzeug.exceptions import BadRequest
class ParentTraceContext(BaseModel):
@ -72,6 +73,69 @@ def extract_external_trace_id_from_args(args: Mapping[str, Any]):
return {}
TRACE_SESSION_ID_HEADER = "X-Trace-Session-Id"
TRACE_SESSION_ID_ARG = "trace_session_id"
TRACE_SESSION_ID_MAX_LENGTH = 200
def _validate_trace_session_id(value: Any) -> str:
if not isinstance(value, str):
raise BadRequest("trace_session_id must be a string.")
normalized = value.strip()
if not normalized:
raise BadRequest("trace_session_id must be 1 to 200 characters after trimming.")
if len(normalized) > TRACE_SESSION_ID_MAX_LENGTH:
raise BadRequest("trace_session_id must be 1 to 200 characters after trimming.")
return normalized
def get_trace_session_id(request: Any) -> str | None:
"""
Resolve the Service API trace session ID from explicit request inputs.
Priority is ``X-Trace-Session-Id`` header, then ``trace_session_id`` query
parameter, then ``trace_session_id`` JSON body field. Only the resolved
highest-priority input is validated; lower-priority values are ignored.
"""
if TRACE_SESSION_ID_HEADER in request.headers:
return _validate_trace_session_id(request.headers.get(TRACE_SESSION_ID_HEADER))
if TRACE_SESSION_ID_ARG in request.args:
return _validate_trace_session_id(request.args.get(TRACE_SESSION_ID_ARG))
if getattr(request, "is_json", False):
json_data = getattr(request, "json", None)
if isinstance(json_data, Mapping) and TRACE_SESSION_ID_ARG in json_data:
return _validate_trace_session_id(json_data.get(TRACE_SESSION_ID_ARG))
return None
def extract_trace_session_id_from_args(args: Mapping[str, Any]) -> dict[str, str]:
"""
Extract normalized ``trace_session_id`` from generation args for entity extras.
"""
trace_session_id = args.get(TRACE_SESSION_ID_ARG)
if isinstance(trace_session_id, str):
normalized = trace_session_id.strip()
if normalized:
return {TRACE_SESSION_ID_ARG: normalized}
return {}
def omit_trace_session_id_from_payload(payload: Any) -> Any:
"""
Return a payload copy without transport-level ``trace_session_id``.
Controllers validate this field through :func:`get_trace_session_id` so lower-priority
body values cannot fail DTO validation before header/query priority is applied.
"""
if isinstance(payload, Mapping) and TRACE_SESSION_ID_ARG in payload:
return {key: value for key, value in payload.items() if key != TRACE_SESSION_ID_ARG}
return payload
def extract_parent_trace_context_from_args(args: Mapping[str, Any]) -> dict[str, ParentTraceContext]:
"""
Extract 'parent_trace_context' from args.

View File

@ -5,6 +5,7 @@ import os
import queue
import threading
import time
from collections.abc import Mapping
from datetime import timedelta
from typing import TYPE_CHECKING, Any, TypedDict
from uuid import UUID, uuid4
@ -64,6 +65,11 @@ def _dump_parent_trace_context(parent_trace_context: Any) -> dict[str, str] | No
return None
def _get_trace_session_id(kwargs: Mapping[str, Any]) -> str | None:
value = kwargs.get("trace_session_id")
return value if isinstance(value, str) and value else None
class _AppTracingConfig(TypedDict, total=False):
enabled: bool
tracing_provider: str | None
@ -873,6 +879,10 @@ class TraceTask:
if dumped_parent_trace_context:
metadata["parent_trace_context"] = dumped_parent_trace_context
trace_session_id = _get_trace_session_id(self.kwargs)
if trace_session_id:
metadata["trace_session_id"] = trace_session_id
workflow_trace_info = WorkflowTraceInfo(
trace_id=self.trace_id,
workflow_data=workflow_run.to_dict(),
@ -956,6 +966,10 @@ class TraceTask:
if node_execution_id := kwargs.get("node_execution_id"):
metadata["node_execution_id"] = node_execution_id
trace_session_id = _get_trace_session_id(kwargs)
if trace_session_id:
metadata["trace_session_id"] = trace_session_id
message_tokens = message_data.message_tokens
message_trace_info = MessageTraceInfo(

View File

@ -9,7 +9,11 @@ from sqlalchemy import select
from core.app.file_access import DatabaseFileAccessController
from core.db.session_factory import session_factory
from core.helper.trace_id_helper import ParentTraceContext, extract_parent_trace_context_from_args
from core.helper.trace_id_helper import (
ParentTraceContext,
extract_parent_trace_context_from_args,
extract_trace_session_id_from_args,
)
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.tool_entities import (
@ -38,6 +42,7 @@ class WorkflowTool(Tool):
"""
_parent_trace_context: ParentTraceContext | None
_trace_session_id: str | None
def __init__(
self,
@ -58,6 +63,7 @@ class WorkflowTool(Tool):
self.label = label
self._latest_usage = LLMUsage.empty_usage()
self._parent_trace_context = None
self._trace_session_id = None
super().__init__(entity=entity, runtime=runtime)
@ -103,6 +109,8 @@ class WorkflowTool(Tool):
generator_args.update(
extract_parent_trace_context_from_args({"parent_trace_context": self._parent_trace_context})
)
if self._trace_session_id:
generator_args.update(extract_trace_session_id_from_args({"trace_session_id": self._trace_session_id}))
result = generator.generate(
app_model=app,
@ -215,6 +223,7 @@ class WorkflowTool(Tool):
label=self.label,
)
forked._parent_trace_context = self._parent_trace_context.model_copy() if self._parent_trace_context else None
forked._trace_session_id = self._trace_session_id
return forked
def set_parent_trace_context(
@ -233,6 +242,14 @@ class WorkflowTool(Tool):
"""Remove parent trace context before invoking this tool outside a nested workflow."""
self._parent_trace_context = None
def set_trace_session_id(self, trace_session_id: str) -> None:
"""Attach parent trace session ID without exposing it as tool input."""
self._trace_session_id = trace_session_id
def clear_trace_session_id(self) -> None:
"""Remove trace session ID before invoking this tool outside a traced session."""
self._trace_session_id = None
def _resolve_user(self, user_id: str) -> Account | EndUser | None:
"""
Resolve user object in both HTTP and worker contexts.

View File

@ -382,6 +382,7 @@ class _WorkflowToolRuntimeBinding:
tool: Tool
conversation_id: str | None = None
parent_trace_context: ParentTraceContext | None = None
trace_session_id: str | None = None
class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
@ -423,6 +424,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
None if variable_pool is None else get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID)
)
parent_trace_context: ParentTraceContext | None = None
trace_session_id: str | None = None
if self._is_workflow_tool_provider(node_data):
outer_workflow_run_id = (
None
@ -434,11 +436,14 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
parent_workflow_run_id=outer_workflow_run_id,
parent_node_execution_id=node_execution_id,
)
if isinstance(self._run_context.trace_session_id, str) and self._run_context.trace_session_id:
trace_session_id = self._run_context.trace_session_id
return ToolRuntimeHandle(
raw=_WorkflowToolRuntimeBinding(
tool=tool_runtime,
conversation_id=conversation_id,
parent_trace_context=parent_trace_context,
trace_session_id=trace_session_id,
)
)
@ -471,6 +476,10 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
)
elif hasattr(tool, "clear_parent_trace_context"):
tool.clear_parent_trace_context()
if runtime_binding.trace_session_id and hasattr(tool, "set_trace_session_id"):
tool.set_trace_session_id(runtime_binding.trace_session_id)
elif hasattr(tool, "clear_trace_session_id"):
tool.clear_trace_session_id()
try:
messages = ToolEngine.generic_invoke(

View File

@ -2233,6 +2233,7 @@ Returns a list of available models for the specified model type.
| query | string | | Yes |
| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No |
| retriever_from | string | | No |
| trace_session_id | string | Trace session ID for observability grouping | No |
| workflow_id | string | Workflow ID for advanced chat | No |
#### ChildChunkCreatePayload
@ -2293,6 +2294,7 @@ Returns a list of available models for the specified model type.
| query | string | | No |
| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No |
| retriever_from | string | | No |
| trace_session_id | string | Trace session ID for observability grouping | No |
#### Condition
@ -3381,6 +3383,7 @@ Accept the legacy single-tag Service API payload while exposing a normalized tag
| files | [ object ] | | No |
| inputs | object | | Yes |
| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No |
| trace_session_id | string | Trace session ID for observability grouping | No |
#### WorkflowRunResponse

View File

@ -300,8 +300,12 @@ def _get_node_span_kind(node_type: str) -> OpenInferenceSpanKindValues:
return _NODE_TYPE_TO_SPAN_KIND.get(node_type, OpenInferenceSpanKindValues.CHAIN)
def _resolve_workflow_session_id(trace_info: WorkflowTraceInfo) -> str:
"""Resolve the workflow session ID for Phoenix workflow spans."""
def _metadata_trace_session_id(trace_info: BaseTraceInfo) -> str | None:
value = trace_info.metadata.get("trace_session_id")
return value if isinstance(value, str) and value else None
def _resolve_workflow_session_fallback(trace_info: WorkflowTraceInfo) -> str:
if trace_info.conversation_id:
return trace_info.conversation_id
@ -312,6 +316,28 @@ def _resolve_workflow_session_id(trace_info: WorkflowTraceInfo) -> str:
return trace_info.workflow_run_id
def _resolve_message_session_fallback(trace_info: MessageTraceInfo) -> str:
if trace_info.message_data is not None:
conversation_id = getattr(trace_info.message_data, "conversation_id", None)
if conversation_id:
return conversation_id
return ""
def _resolve_trace_session_id(trace_info: WorkflowTraceInfo | MessageTraceInfo) -> str:
trace_session_id = _metadata_trace_session_id(trace_info)
if trace_session_id:
return trace_session_id
if isinstance(trace_info, WorkflowTraceInfo):
return _resolve_workflow_session_fallback(trace_info)
return _resolve_message_session_fallback(trace_info)
def _resolve_workflow_session_id(trace_info: WorkflowTraceInfo) -> str:
"""Resolve the workflow session ID for Phoenix workflow spans."""
return _resolve_trace_session_id(trace_info)
def _resolve_workflow_parent_context(trace_info: BaseTraceInfo) -> tuple[str | None, str | None]:
"""Expose the typed parent context already resolved on the trace info."""
return trace_info.resolved_parent_context
@ -752,7 +778,7 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
file_list=safe_json_dumps(file_list),
query=trace_info.query or "",
)
workflow_session_id = _resolve_workflow_session_id(trace_info)
workflow_session_id = _resolve_trace_session_id(trace_info)
parent_workflow_run_id, parent_node_execution_id = _resolve_workflow_parent_context(trace_info)
logger.info(
"[Arize/Phoenix] Workflow session resolution: workflow_run_id=%s conversation_id=%s "
@ -781,6 +807,7 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
SpanAttributes.INPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value,
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs),
SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value,
SpanAttributes.SESSION_ID: workflow_session_id or "",
},
}
if trace_info.error:
@ -1090,6 +1117,7 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
model_provider=trace_info.message_data.model_provider or "",
model_id=trace_info.message_data.model_id or "",
)
message_session_id = _resolve_trace_session_id(trace_info)
# Add end user data if available
if trace_info.message_data.from_end_user_id:
@ -1104,7 +1132,7 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
SpanAttributes.OUTPUT_VALUE: trace_info.message_data.answer,
SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.TEXT.value,
SpanAttributes.METADATA: safe_json_dumps(metadata),
SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id or "",
SpanAttributes.SESSION_ID: message_session_id or "",
}
dify_trace_id = trace_info.trace_id or trace_info.message_id
@ -1129,14 +1157,14 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
else:
outputs_str = str(trace_info.outputs)
llm_attributes = {
llm_attributes: dict[str, Any] = {
SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.LLM.value,
SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.inputs),
SpanAttributes.INPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value,
SpanAttributes.OUTPUT_VALUE: outputs_str,
SpanAttributes.OUTPUT_MIME_TYPE: outputs_mime_type,
SpanAttributes.METADATA: safe_json_dumps(metadata),
SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id or "",
SpanAttributes.SESSION_ID: message_session_id or "",
}
llm_attributes.update(self._construct_llm_attributes(trace_info.inputs))
if trace_info.total_tokens is not None and trace_info.total_tokens > 0:

View File

@ -20,6 +20,7 @@ from dify_trace_arize_phoenix.arize_phoenix_trace import (
_resolve_node_parent,
_resolve_published_parent_span_context,
_resolve_structured_parent_execution_id,
_resolve_trace_session_id,
_resolve_workflow_parent_context,
_resolve_workflow_session_id,
datetime_to_nanos,
@ -106,6 +107,14 @@ def _get_start_span_call(start_span_mock, *, span_name: str):
raise AssertionError(f"Could not find start_span call with name={span_name!r}")
def _get_start_span_call_by_kind(start_span_mock, *, span_kind: str):
for call in start_span_mock.call_args_list:
attributes = call.kwargs.get("attributes", {})
if attributes.get(SpanAttributes.OPENINFERENCE_SPAN_KIND) == span_kind:
return call
raise AssertionError(f"Could not find start_span call with span kind={span_kind!r}")
class _FakeQuery:
def __init__(self, result):
self._result = result
@ -358,6 +367,34 @@ class TestGetNodeSpanKind:
class TestWorkflowSessionResolution:
def test_resolve_workflow_session_id_prefers_trace_session_id_metadata(self):
trace_info = _make_workflow_info(
conversation_id="conversation-1",
workflow_run_id="workflow-run-1",
metadata={"app_id": "app-1", "trace_session_id": "session-1"},
)
assert _resolve_trace_session_id(trace_info) == "session-1"
assert _resolve_workflow_session_id(trace_info) == "session-1"
def test_resolve_workflow_session_id_falls_back_to_existing_workflow_behavior(self):
trace_info = _make_workflow_info(
conversation_id="conversation-1",
workflow_run_id="workflow-run-1",
metadata={"app_id": "app-1"},
)
assert _resolve_trace_session_id(trace_info) == "conversation-1"
def test_resolve_message_session_id_prefers_trace_session_id_metadata(self):
message_data = SimpleNamespace(conversation_id="conversation-1")
trace_info = _make_message_info(
message_data=message_data,
metadata={"app_id": "app-1", "trace_session_id": "session-1"},
)
assert _resolve_trace_session_id(trace_info) == "session-1"
def test_prefers_conversation_id(self):
info = _make_workflow_trace_info(conversation_id="conversation-1")
@ -780,7 +817,11 @@ def test_workflow_trace_uses_canonical_root_context_for_top_level_workflow(
mock_sessionmaker, mock_repo_factory, mock_db, trace_instance
):
mock_db.engine = MagicMock()
info = _make_workflow_info(message_id="message-1", workflow_run_id="workflow-run-1")
info = _make_workflow_info(
message_id="message-1",
workflow_run_id="workflow-run-1",
metadata={"app_id": "app1", "trace_session_id": "trace-session-1"},
)
repo = MagicMock()
repo.get_by_workflow_execution.return_value = []
mock_repo_factory.create_workflow_node_execution_repository.return_value = repo
@ -803,6 +844,7 @@ def test_workflow_trace_uses_canonical_root_context_for_top_level_workflow(
SpanAttributes.INPUT_MIME_TYPE: "application/json",
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs),
SpanAttributes.OUTPUT_MIME_TYPE: "application/json",
SpanAttributes.SESSION_ID: "trace-session-1",
},
)
mock_extract.assert_called_once_with(carrier=root_carrier)
@ -940,6 +982,7 @@ def test_workflow_trace_reuses_upstream_parent_workflow_context_when_no_parent_n
SpanAttributes.INPUT_MIME_TYPE: "application/json",
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs),
SpanAttributes.OUTPUT_MIME_TYPE: "application/json",
SpanAttributes.SESSION_ID: "outer-workflow-run-1",
},
)
mock_extract.assert_called_once_with(carrier=parent_carrier)
@ -1085,6 +1128,7 @@ def test_workflow_trace_falls_back_when_parent_app_tracing_cannot_publish_parent
SpanAttributes.INPUT_MIME_TYPE: "application/json",
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs),
SpanAttributes.OUTPUT_MIME_TYPE: "application/json",
SpanAttributes.SESSION_ID: "outer-workflow-run-1",
},
)
mock_extract.assert_called_once_with(carrier=parent_carrier)
@ -1287,6 +1331,7 @@ def test_workflow_trace_keeps_nested_conversation_session_while_reusing_parent_r
SpanAttributes.INPUT_MIME_TYPE: "application/json",
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs),
SpanAttributes.OUTPUT_MIME_TYPE: "application/json",
SpanAttributes.SESSION_ID: "conversation-1",
},
)
mock_extract.assert_called_once_with(carrier=parent_carrier)
@ -1926,6 +1971,40 @@ def test_message_trace_keeps_conversation_id_as_session(mock_db, trace_instance)
assert message_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-2"
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db")
def test_message_trace_uses_trace_session_id_metadata_as_session(mock_db, trace_instance):
mock_db.engine = MagicMock()
info = _make_message_info(metadata={"app_id": "app-1", "trace_session_id": "session-1"})
info.message_data = MagicMock()
info.message_data.conversation_id = "conversation-2"
info.message_data.from_account_id = "acc2"
info.message_data.from_end_user_id = None
info.message_data.query = "q2"
info.message_data.answer = "a2"
info.message_data.status = "s2"
info.message_data.model_id = "m2"
info.message_data.model_provider = "p2"
info.message_data.message_metadata = "{}"
info.message_data.error = None
info.error = None
root_span = MagicMock()
message_span = MagicMock()
llm_span = MagicMock()
trace_instance.tracer.start_span.side_effect = [root_span, message_span, llm_span]
trace_instance.message_trace(info)
message_span_call = _get_start_span_call(
trace_instance.tracer.start_span, span_name=TraceTaskName.MESSAGE_TRACE.value
)
llm_span_call = _get_start_span_call_by_kind(
trace_instance.tracer.start_span, span_kind=OpenInferenceSpanKindValues.LLM.value
)
assert message_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "session-1"
assert llm_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "session-1"
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db")
def test_message_trace_with_error(mock_db, trace_instance):
mock_db.engine = MagicMock()

View File

@ -0,0 +1,180 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import BadRequest
from controllers.service_api.app import completion as completion_module
from controllers.service_api.app import workflow as workflow_module
from core.helper.trace_id_helper import get_trace_session_id
from models.model import AppMode
class _Request:
def __init__(self, *, headers=None, args=None, json=None, is_json=True):
self.headers = headers or {}
self.args = args or {}
self.json = json
self.is_json = is_json
def test_trace_session_id_header_query_body_priority_matches_service_api_contract():
req = _Request(
headers={"X-Trace-Session-Id": "header"},
args={"trace_session_id": "query"},
json={"trace_session_id": "body"},
)
assert get_trace_session_id(req) == "header"
def test_trace_session_id_invalid_highest_priority_raises_bad_request():
req = _Request(
headers={"X-Trace-Session-Id": " "},
args={"trace_session_id": "query"},
json={"trace_session_id": "body"},
)
with pytest.raises(BadRequest):
get_trace_session_id(req)
def _app(mode: AppMode) -> SimpleNamespace:
return SimpleNamespace(id="app-1", mode=mode, tenant_id="tenant-1")
def _end_user() -> SimpleNamespace:
return SimpleNamespace(id="user-1")
def _assert_generate_trace_session_id(mock_generate_service: MagicMock, expected: str) -> None:
_, kwargs = mock_generate_service.generate.call_args
assert kwargs["args"]["trace_session_id"] == expected
@patch("controllers.service_api.app.completion.AppGenerateService")
@patch("controllers.service_api.app.completion.service_api_ns")
def test_chat_api_rejects_invalid_highest_priority_query_trace_session_id_without_generating(
mock_service_api_ns: MagicMock,
mock_generate_service: MagicMock,
app: Flask,
):
payload = {"inputs": {}, "query": "hello", "trace_session_id": "body-session"}
mock_service_api_ns.payload = payload
with app.test_request_context(
"/chat-messages?trace_session_id=%20%20%20",
method="POST",
json=payload,
):
with pytest.raises(BadRequest):
completion_module.ChatApi().post.__wrapped__(
completion_module.ChatApi(),
_app(AppMode.CHAT),
_end_user(),
)
mock_generate_service.generate.assert_not_called()
@patch("controllers.service_api.app.workflow.AppGenerateService")
@patch("controllers.service_api.app.workflow.service_api_ns")
def test_workflow_run_api_rejects_invalid_highest_priority_body_trace_session_id_without_generating(
mock_service_api_ns: MagicMock,
mock_generate_service: MagicMock,
app: Flask,
):
payload = {"inputs": {}, "trace_session_id": 123}
mock_service_api_ns.payload = payload
with app.test_request_context("/workflows/run", method="POST", json=payload):
with pytest.raises(BadRequest):
workflow_module.WorkflowRunApi().post.__wrapped__(
workflow_module.WorkflowRunApi(),
_app(AppMode.WORKFLOW),
_end_user(),
)
mock_generate_service.generate.assert_not_called()
@patch("controllers.service_api.app.completion.helper.compact_generate_response", return_value={"answer": "ok"})
@patch("controllers.service_api.app.completion.AppGenerateService")
@patch("controllers.service_api.app.completion.service_api_ns")
def test_completion_api_passes_header_trace_session_id_when_body_value_is_invalid_lower_priority(
mock_service_api_ns: MagicMock,
mock_generate_service: MagicMock,
mock_compact: MagicMock,
app: Flask,
):
payload = {"inputs": {}, "trace_session_id": 123}
mock_service_api_ns.payload = payload
mock_generate_service.generate.return_value = "response"
with app.test_request_context(
"/completion-messages",
method="POST",
json=payload,
headers={"X-Trace-Session-Id": " header-session "},
):
response = completion_module.CompletionApi().post.__wrapped__(
completion_module.CompletionApi(),
_app(AppMode.COMPLETION),
_end_user(),
)
assert response == {"answer": "ok"}
_assert_generate_trace_session_id(mock_generate_service, "header-session")
@patch("controllers.service_api.app.completion.helper.compact_generate_response", return_value={"answer": "ok"})
@patch("controllers.service_api.app.completion.AppGenerateService")
@patch("controllers.service_api.app.completion.service_api_ns")
def test_chat_api_passes_query_trace_session_id_when_body_value_is_invalid_lower_priority(
mock_service_api_ns: MagicMock,
mock_generate_service: MagicMock,
mock_compact: MagicMock,
app: Flask,
):
payload = {"inputs": {}, "query": "hello", "trace_session_id": 123}
mock_service_api_ns.payload = payload
mock_generate_service.generate.return_value = "response"
with app.test_request_context(
"/chat-messages?trace_session_id=query-session",
method="POST",
json=payload,
):
response = completion_module.ChatApi().post.__wrapped__(
completion_module.ChatApi(),
_app(AppMode.CHAT),
_end_user(),
)
assert response == {"answer": "ok"}
_assert_generate_trace_session_id(mock_generate_service, "query-session")
@patch("controllers.service_api.app.workflow.helper.compact_generate_response", return_value={"result": "ok"})
@patch("controllers.service_api.app.workflow.AppGenerateService")
@patch("controllers.service_api.app.workflow.service_api_ns")
def test_workflow_run_api_passes_body_trace_session_id(
mock_service_api_ns: MagicMock,
mock_generate_service: MagicMock,
mock_compact: MagicMock,
app: Flask,
):
payload = {"inputs": {}, "trace_session_id": " body-session "}
mock_service_api_ns.payload = payload
mock_generate_service.generate.return_value = "response"
with app.test_request_context("/workflows/run", method="POST", json=payload):
response = workflow_module.WorkflowRunApi().post.__wrapped__(
workflow_module.WorkflowRunApi(),
_app(AppMode.WORKFLOW),
_end_user(),
)
assert response == {"result": "ok"}
_assert_generate_trace_session_id(mock_generate_service, "body-session")

View File

@ -290,7 +290,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow=workflow,
node_id="node-1",
user=SimpleNamespace(id="user-id"),
args={"inputs": {"foo": "bar"}},
args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"},
streaming=False,
)
@ -298,6 +298,7 @@ class TestAdvancedChatAppGeneratorInternals:
assert prefill_calls == [(workflow, "user-id")]
assert captured["variable_loader"] is var_loader
assert captured["application_generate_entity"].single_iteration_run.node_id == "node-1"
assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1"
def test_single_loop_generate_builds_debug_task(self, monkeypatch: pytest.MonkeyPatch):
generator = AdvancedChatAppGenerator()
@ -348,7 +349,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow=workflow,
node_id="node-2",
user=SimpleNamespace(id="user-id"),
args=SimpleNamespace(inputs={"foo": "bar"}),
args=SimpleNamespace(inputs={"foo": "bar"}, trace_session_id="session-1"),
streaming=False,
)
@ -356,6 +357,7 @@ class TestAdvancedChatAppGeneratorInternals:
assert prefill_calls == [(workflow, "user-id")]
assert captured["variable_loader"] is var_loader
assert captured["application_generate_entity"].single_loop_run.node_id == "node-2"
assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1"
def test_generate_internal_flow_initial_conversation_with_pause_layer(self, monkeypatch: pytest.MonkeyPatch):
generator = AdvancedChatAppGenerator()

View File

@ -99,6 +99,7 @@ class TestAdvancedChatAppRunnerConversationVariables:
mock_app_generate_entity.call_depth = 0
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.extras = {}
mock_app_generate_entity.trace_manager = None
# Create runner
@ -244,6 +245,7 @@ class TestAdvancedChatAppRunnerConversationVariables:
mock_app_generate_entity.call_depth = 0
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.extras = {}
mock_app_generate_entity.trace_manager = None
# Create runner
@ -404,6 +406,7 @@ class TestAdvancedChatAppRunnerConversationVariables:
mock_app_generate_entity.call_depth = 0
mock_app_generate_entity.single_iteration_run = None
mock_app_generate_entity.single_loop_run = None
mock_app_generate_entity.extras = {}
mock_app_generate_entity.trace_manager = None
# Create runner

View File

@ -63,6 +63,7 @@ def build_runner():
gen.call_depth = 0
gen.single_iteration_run = None
gen.single_loop_run = None
gen.extras = {}
gen.trace_manager = None
runner = AdvancedChatAppRunner(

View File

@ -134,6 +134,42 @@ class TestGenerateSuccess:
get_conv.assert_called_once()
def test_generate_does_not_include_trace_session_id_in_extras(self, generator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
user = DummyAccount("user")
generator._resolve_agent = mocker.MagicMock(
return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock())
)
generator._prepare_user_inputs = mocker.MagicMock(return_value={})
generator._init_generate_records = mocker.MagicMock(
return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg"))
)
generator._handle_response = mocker.MagicMock(return_value="raw-response")
mocker.patch(
f"{MODULE}.AgentAppConfigManager.get_app_config",
return_value=mocker.MagicMock(variables=[], tenant_id="tenant", app_id="app1"),
)
mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini"))
mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock())
generate_entity = mocker.patch(
f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user")
)
mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock())
mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"})
generator.generate(
app_model=app_model,
user=user,
args={"query": "hello", "inputs": {}, "trace_session_id": "session-1"},
invoke_from=InvokeFrom.WEB_APP,
streaming=True,
)
assert generate_entity.call_args.kwargs["extras"] == {"auto_generate_conversation_name": True}
class TestGenerateWorker:
@pytest.fixture(autouse=True)

View File

@ -125,7 +125,7 @@ class TestAgentChatAppGeneratorGenerate:
return_value={"result": "ok"},
)
app_entity = mocker.MagicMock(task_id="task", user_id="user", invoke_from=invoke_from)
mocker.patch(
generate_entity = mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppGenerateEntity",
return_value=app_entity,
)
@ -136,11 +136,13 @@ class TestAgentChatAppGeneratorGenerate:
"conversation_id": "conv",
"model_config": {"model": {"provider": "p"}},
"files": [{"id": "f1"}],
"trace_session_id": "session-1",
}
result = generator.generate(app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=True)
assert result == {"result": "ok"}
assert generate_entity.call_args.kwargs["extras"]["trace_session_id"] == "session-1"
thread_obj.start.assert_called_once()
def test_generate_without_file_config(self, generator, mocker: MockerFixture):

View File

@ -56,7 +56,7 @@ class TestChatAppGenerator:
generator = ChatAppGenerator()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
user = SimpleNamespace(id="user-1", session_id="session-1")
args = {"query": "hi", "inputs": {}, "model_config": {"foo": "bar"}}
args = {"query": "hi", "inputs": {}, "model_config": {"foo": "bar"}, "trace_session_id": "session-1"}
with (
patch("core.app.apps.chat.app_generator.ConversationService.get_conversation", return_value=None),
@ -70,7 +70,10 @@ class TestChatAppGenerator:
patch("core.app.apps.chat.app_generator.ModelConfigConverter.convert", return_value=SimpleNamespace()),
patch("core.app.apps.chat.app_generator.FileUploadConfigManager.convert", return_value=None),
patch("core.app.apps.chat.app_generator.file_factory.build_from_mappings", return_value=[]),
patch("core.app.apps.chat.app_generator.ChatAppGenerateEntity", DummyGenerateEntity),
patch(
"core.app.apps.chat.app_generator.ChatAppGenerateEntity",
Mock(side_effect=DummyGenerateEntity),
) as generate_entity,
patch("core.app.apps.chat.app_generator.TraceQueueManager", return_value=SimpleNamespace()),
patch("core.app.apps.chat.app_generator.MessageBasedAppQueueManager", DummyQueueManager),
patch(
@ -91,6 +94,7 @@ class TestChatAppGenerator:
result = generator.generate(app_model, user, args, InvokeFrom.DEBUGGER, streaming=False)
assert result == {"ok": True}
assert generate_entity.call_args.kwargs["extras"]["trace_session_id"] == "session-1"
def test_generate_rejects_model_config_override_for_non_debugger(self):
generator = ChatAppGenerator()

View File

@ -30,7 +30,10 @@ def generator(mocker: MockerFixture):
mocker.patch.object(module, "MessageBasedAppQueueManager", return_value=MagicMock())
mocker.patch.object(module, "TraceQueueManager", return_value=MagicMock())
mocker.patch.object(module, "CompletionAppGenerateEntity", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
generate_entity = mocker.patch.object(
module, "CompletionAppGenerateEntity", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)
)
gen.generate_entity = generate_entity
return gen
@ -92,12 +95,13 @@ class TestCompletionAppGenerator:
result = generator.generate(
app_model=_build_app_model(),
user=_build_user(),
args={"query": "q", "inputs": {"a": 1}, "files": []},
args={"query": "q", "inputs": {"a": 1}, "files": [], "trace_session_id": "session-1"},
invoke_from=InvokeFrom.WEB_APP,
streaming=True,
)
assert result == "converted"
assert generator.generate_entity.call_args.kwargs["extras"]["trace_session_id"] == "session-1"
module.file_factory.build_from_mappings.assert_not_called()
def test_generate_success_with_files(self, generator, mocker: MockerFixture):

View File

@ -0,0 +1,11 @@
from core.helper.trace_id_helper import extract_trace_session_id_from_args
def test_extract_trace_session_id_from_args_for_generator_extras():
assert extract_trace_session_id_from_args({"trace_session_id": "session-1"}) == {
"trace_session_id": "session-1",
}
def test_extract_trace_session_id_from_args_missing_value_keeps_extras_clean():
assert extract_trace_session_id_from_args({"inputs": {}}) == {}

View File

@ -80,6 +80,7 @@ def test_generate_includes_parent_trace_context_in_extras(monkeypatch):
"parent_workflow_run_id": "outer-workflow-run-1",
"parent_node_execution_id": "outer-node-execution-1",
},
"trace_session_id": "session-1",
},
invoke_from="service-api",
streaming=False,
@ -93,6 +94,7 @@ def test_generate_includes_parent_trace_context_in_extras(monkeypatch):
"parent_workflow_run_id": "outer-workflow-run-1",
"parent_node_execution_id": "outer-node-execution-1",
}
assert extras["trace_session_id"] == "session-1"
def test_resume_delegates_to_generate(mocker: MockerFixture):

View File

@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom
from core.app.entities.queue_entities import (
QueueAgentLogEvent,
QueueHumanInputFormFilledEvent,
@ -85,6 +85,35 @@ class TestWorkflowBasedAppRunner:
invoke_from=InvokeFrom.DEBUGGER,
)
def test_init_graph_includes_trace_session_id_in_run_context(self, monkeypatch: pytest.MonkeyPatch):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
runtime_state = GraphRuntimeState(
variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()),
start_at=0.0,
)
captured = {}
def fake_from_graph_init_context(**kwargs):
captured["run_context"] = kwargs["graph_init_context"].run_context
return SimpleNamespace()
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.DifyNodeFactory.from_graph_init_context",
fake_from_graph_init_context,
)
monkeypatch.setattr("core.app.apps.workflow_app_runner.Graph.init", lambda **_kwargs: SimpleNamespace())
runner._init_graph(
graph_config={"nodes": [], "edges": []},
graph_runtime_state=runtime_state,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
root_node_id="root",
trace_session_id="session-1",
)
assert captured["run_context"][DIFY_RUN_CONTEXT_KEY].trace_session_id == "session-1"
def test_prepare_single_node_execution_requires_run(self):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
@ -145,6 +174,57 @@ class TestWorkflowBasedAppRunner:
assert graph is not None
assert variable_pool is graph_runtime_state.variable_pool
def test_get_graph_and_variable_pool_for_single_node_run_includes_trace_session_id(
self, monkeypatch: pytest.MonkeyPatch
):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()),
start_at=0.0,
)
graph_config = {
"nodes": [{"id": "node-1", "data": {"type": "start", "version": "1"}}],
"edges": [],
}
workflow = SimpleNamespace(tenant_id="tenant", id="workflow", graph_dict=graph_config)
captured = {}
def fake_from_graph_init_context(**kwargs):
captured["run_context"] = kwargs["graph_init_context"].run_context
return SimpleNamespace()
class _NodeCls:
@staticmethod
def extract_variable_selector_to_variable_mapping(graph_config, config):
return {}
from core.app.apps import workflow_app_runner
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.DifyNodeFactory.from_graph_init_context",
fake_from_graph_init_context,
)
monkeypatch.setattr("core.app.apps.workflow_app_runner.Graph.init", lambda **kwargs: SimpleNamespace())
monkeypatch.setattr(workflow_app_runner, "resolve_workflow_node_class", lambda **_kwargs: _NodeCls)
monkeypatch.setattr("core.app.apps.workflow_app_runner.load_into_variable_pool", lambda **kwargs: None)
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.WorkflowEntry.mapping_user_inputs_to_variable_pool",
lambda **kwargs: None,
)
runner._get_graph_and_variable_pool_for_single_node_run(
workflow=workflow,
node_id="node-1",
user_inputs={},
graph_runtime_state=graph_runtime_state,
node_type_filter_key="iteration_id",
node_type_label="iteration",
user_id="00000000-0000-0000-0000-000000000001",
trace_session_id="session-1",
)
assert captured["run_context"][DIFY_RUN_CONTEXT_KEY].trace_session_id == "session-1"
def test_get_graph_and_variable_pool_preloads_constructor_variables_before_graph_init(
self, monkeypatch: pytest.MonkeyPatch
):

View File

@ -51,6 +51,7 @@ def test_run_uses_single_node_execution_branch(
app_generate_entity.task_id = "task-id"
app_generate_entity.call_depth = 0
app_generate_entity.trace_manager = None
app_generate_entity.extras = {"trace_session_id": "session-1"}
app_generate_entity.single_iteration_run = single_iteration_run
app_generate_entity.single_loop_run = single_loop_run
@ -101,6 +102,7 @@ def test_run_uses_single_node_execution_branch(
single_iteration_run=single_iteration_run,
single_loop_run=single_loop_run,
user_id="user",
trace_session_id="session-1",
)
init_graph.assert_not_called()

View File

@ -55,6 +55,100 @@ class TestWorkflowAppGeneratorValidation:
streaming=False,
)
def test_single_iteration_generate_includes_trace_session_id_in_extras(self, monkeypatch: pytest.MonkeyPatch):
generator = WorkflowAppGenerator()
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
captured: dict[str, object] = {}
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config",
lambda **kwargs: app_config,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository",
lambda **kwargs: SimpleNamespace(),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository",
lambda **kwargs: SimpleNamespace(),
)
monkeypatch.setattr("core.app.apps.workflow.app_generator.DraftVarLoader", lambda **kwargs: SimpleNamespace())
monkeypatch.setattr("core.app.apps.workflow.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace())
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.db",
SimpleNamespace(engine=object(), session=lambda: SimpleNamespace()),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowDraftVariableService",
lambda session: SimpleNamespace(prefill_conversation_variable_default_values=lambda *args, **kwargs: None),
)
monkeypatch.setattr(generator, "_generate", lambda **kwargs: captured.update(kwargs) or {"ok": True})
generator.single_iteration_generate(
app_model=SimpleNamespace(id="app", tenant_id="tenant"),
workflow=SimpleNamespace(id="workflow-id"),
node_id="node-1",
user=SimpleNamespace(id="user-id"),
args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"},
streaming=False,
)
assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1"
def test_single_loop_generate_includes_trace_session_id_in_extras(self, monkeypatch: pytest.MonkeyPatch):
generator = WorkflowAppGenerator()
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
captured: dict[str, object] = {}
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config",
lambda **kwargs: app_config,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository",
lambda **kwargs: SimpleNamespace(),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository",
lambda **kwargs: SimpleNamespace(),
)
monkeypatch.setattr("core.app.apps.workflow.app_generator.DraftVarLoader", lambda **kwargs: SimpleNamespace())
monkeypatch.setattr("core.app.apps.workflow.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace())
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.db",
SimpleNamespace(engine=object(), session=lambda: SimpleNamespace()),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowDraftVariableService",
lambda session: SimpleNamespace(prefill_conversation_variable_default_values=lambda *args, **kwargs: None),
)
monkeypatch.setattr(generator, "_generate", lambda **kwargs: captured.update(kwargs) or {"ok": True})
generator.single_loop_generate(
app_model=SimpleNamespace(id="app", tenant_id="tenant"),
workflow=SimpleNamespace(id="workflow-id"),
node_id="node-2",
user=SimpleNamespace(id="user-id"),
args=SimpleNamespace(inputs={"foo": "bar"}, trace_session_id="session-1"),
streaming=False,
)
assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1"
with pytest.raises(ValueError, match="inputs is required"):
generator.single_loop_generate(
app_model=SimpleNamespace(),

View File

@ -351,6 +351,7 @@ def _build_workflow_generate_entity_for_roundtrip() -> WorkflowResumptionContext
stream=False,
invoke_from=InvokeFrom.DEBUGGER,
workflow_execution_id="workflow-exec-roundtrip",
extras={"trace_session_id": "session-1"},
)
),
)
@ -379,6 +380,7 @@ def _build_advanced_chat_generate_entity_for_roundtrip() -> WorkflowResumptionCo
invoke_from=InvokeFrom.DEBUGGER,
workflow_run_id="advanced-run-id",
query="Explain serialization behavior",
extras={"trace_session_id": "session-1"},
)
),
)
@ -406,3 +408,4 @@ def test_workflow_resumption_context_dumps_loads_roundtrip(state: WorkflowResump
assert loaded.serialized_graph_runtime_state == state.serialized_graph_runtime_state
restored_entity = loaded.get_generate_entity()
assert isinstance(restored_entity, type(state.generate_entity.entity))
assert restored_entity.extras["trace_session_id"] == "session-1"

View File

@ -38,6 +38,7 @@ from core.app.entities.task_entities import (
)
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
from core.base.tts import AudioTrunk
from core.ops.entities.trace_entity import TraceTaskName
from graphon.file import FileTransferMethod
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, TextPromptMessageContent
@ -899,8 +900,10 @@ class TestEasyUiBasedGenerateTaskPipeline:
def test_save_message_persists_fields_and_emits_trace(self, monkeypatch: pytest.MonkeyPatch):
conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT)
message = SimpleNamespace(id="msg", created_at=datetime.now(UTC))
application_generate_entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT)
application_generate_entity.extras = {"trace_session_id": "session-1"}
pipeline = EasyUIBasedGenerateTaskPipeline(
application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT),
application_generate_entity=application_generate_entity,
queue_manager=SimpleNamespace(),
conversation=conversation,
message=message,
@ -946,7 +949,12 @@ class TestEasyUiBasedGenerateTaskPipeline:
assert message_obj.message == "serialized-prompt"
assert message_obj.answer == "hello"
assert message_obj.provider_response_latency == 5.0
assert trace_manager.add_trace_task.called
trace_manager.add_trace_task.assert_called_once()
trace_task = trace_manager.add_trace_task.call_args.args[0]
assert trace_task.trace_type == TraceTaskName.MESSAGE_TRACE
assert trace_task.conversation_id == "conv"
assert trace_task.message_id == "msg"
assert trace_task.kwargs["trace_session_id"] == "session-1"
assert len(sent_payloads) == 1
def test_save_message_raises_when_message_not_found(self):

View File

@ -224,6 +224,7 @@ class TestWorkflowPersistenceLayer:
layer, _, _, _ = _make_layer(
extras={
"external_trace_id": "trace",
"trace_session_id": "session-1",
"parent_trace_context": {
"parent_workflow_run_id": "outer-workflow-run-1",
"parent_node_execution_id": "outer-node-execution-1",
@ -245,6 +246,7 @@ class TestWorkflowPersistenceLayer:
):
captured["trace_type"] = self.trace_type
captured["external_trace_id"] = self.kwargs.get("external_trace_id")
captured["trace_session_id"] = self.kwargs.get("trace_session_id")
captured["parent_trace_context"] = self.kwargs.get("parent_trace_context")
captured["workflow_run_id"] = workflow_run_id
return {"ok": True}
@ -257,6 +259,7 @@ class TestWorkflowPersistenceLayer:
trace_task = trace_tasks[0]
assert trace_task.trace_type == TraceTaskName.WORKFLOW_TRACE
assert trace_task.kwargs["external_trace_id"] == "trace"
assert trace_task.kwargs["trace_session_id"] == "session-1"
assert trace_task.kwargs["parent_trace_context"] == {
"parent_workflow_run_id": "outer-workflow-run-1",
"parent_node_execution_id": "outer-node-execution-1",
@ -266,6 +269,7 @@ class TestWorkflowPersistenceLayer:
assert captured["trace_type"] == TraceTaskName.WORKFLOW_TRACE
assert captured["external_trace_id"] == "trace"
assert captured["trace_session_id"] == "session-1"
assert captured["parent_trace_context"] == {
"parent_workflow_run_id": "outer-workflow-run-1",
"parent_node_execution_id": "outer-node-execution-1",

View File

@ -1,10 +1,13 @@
import pytest
from werkzeug.exceptions import BadRequest
from core.helper.trace_id_helper import (
ParentTraceContext,
extract_external_trace_id_from_args,
extract_parent_trace_context_from_args,
extract_trace_session_id_from_args,
get_external_trace_id,
get_trace_session_id,
is_valid_trace_id,
)
@ -17,6 +20,90 @@ class DummyRequest:
self.is_json = is_json
class _Request:
def __init__(self, *, headers=None, args=None, json=None, is_json=True):
self.headers = headers or {}
self.args = args or {}
self.json = json
self.is_json = is_json
def test_get_trace_session_id_prefers_header_over_query_and_body():
request = _Request(
headers={"X-Trace-Session-Id": " header-session "},
args={"trace_session_id": "query-session"},
json={"trace_session_id": "body-session"},
)
assert get_trace_session_id(request) == "header-session"
def test_get_trace_session_id_prefers_query_over_body():
request = _Request(
args={"trace_session_id": " query-session "},
json={"trace_session_id": "body-session"},
)
assert get_trace_session_id(request) == "query-session"
def test_get_trace_session_id_reads_body_when_no_higher_priority_input():
request = _Request(json={"trace_session_id": " body/session:123 "})
assert get_trace_session_id(request) == "body/session:123"
def test_get_trace_session_id_ignores_invalid_lower_priority_value():
request = _Request(
headers={"X-Trace-Session-Id": "header-session"},
json={"trace_session_id": " "},
)
assert get_trace_session_id(request) == "header-session"
@pytest.mark.parametrize(
"trace_session_request",
[
_Request(headers={"X-Trace-Session-Id": " "}, json={"trace_session_id": "body-session"}),
_Request(headers={"X-Trace-Session-Id": 123}),
_Request(headers={"X-Trace-Session-Id": "x" * 201}),
],
)
def test_get_trace_session_id_rejects_invalid_highest_priority_input(trace_session_request):
with pytest.raises(BadRequest) as exc_info:
get_trace_session_id(trace_session_request)
assert "trace_session_id" in str(exc_info.value)
def test_get_trace_session_id_does_not_read_trace_id_or_traceparent():
request = _Request(
headers={
"X-Trace-Id": "trace-id",
"traceparent": "00-5b8aa5a2d2c872e8321cf37308d69df2-051581bf3bb55c45-01",
},
args={"trace_id": "query-trace-id"},
json={"trace_id": "body-trace-id"},
)
assert get_trace_session_id(request) is None
def test_extract_trace_session_id_from_args_returns_trimmed_value():
args = {"trace_session_id": " session-1 "}
assert extract_trace_session_id_from_args(args) == {"trace_session_id": "session-1"}
def test_extract_trace_session_id_from_args_returns_empty_dict_when_missing():
assert extract_trace_session_id_from_args({}) == {}
def test_extract_trace_session_id_from_args_returns_empty_dict_when_blank_after_trim():
assert extract_trace_session_id_from_args({"trace_session_id": " "}) == {}
class TestTraceIdHelper:
"""Test cases for trace_id_helper.py"""

View File

@ -0,0 +1,140 @@
import json
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceTask
class _DummySession:
scalar_values: list[object | None] = []
def __init__(self, engine):
self._values = list(self.scalar_values)
self._index = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
return False
def execute(self, *args, **kwargs):
return self
def scalar(self, *args, **kwargs):
if self._index >= len(self._values):
return None
value = self._values[self._index]
self._index += 1
return value
def scalars(self, *args, **kwargs):
return self
def all(self):
return []
def _make_workflow_run():
return SimpleNamespace(
workflow_id="wf-1",
tenant_id="tenant-1",
id="run-1",
elapsed_time=1,
status="succeeded",
inputs_dict={},
outputs_dict={},
version="1",
error=None,
total_tokens=0,
created_at=datetime(2026, 1, 1, 0, 0, 0),
finished_at=datetime(2026, 1, 1, 0, 0, 1),
triggered_from="user",
app_id="app-1",
to_dict=lambda self=None: {"id": "run-1"},
)
def _make_message_data():
created_at = datetime(2026, 1, 1, 0, 0, 0)
data = {
"id": "message-1",
"app_id": "app-1",
"conversation_id": "conv-1",
"created_at": created_at,
"updated_at": created_at + timedelta(seconds=1),
"message": "hello",
"provider_response_latency": 1,
"message_tokens": 0,
"answer_tokens": 0,
"answer": "world",
"error": "",
"status": "normal",
"model_provider": "provider",
"model_id": "model",
"from_end_user_id": "end-user-1",
"from_account_id": None,
"agent_based": False,
"workflow_run_id": None,
"from_source": "api",
"message_metadata": json.dumps({"usage": {}}),
}
class _MessageData:
def __init__(self, values):
self.__dict__.update(values)
def to_dict(self):
return dict(self.__dict__)
return _MessageData(data)
def test_workflow_trace_metadata_includes_trace_session_id(monkeypatch):
repo = MagicMock()
repo.get_workflow_run_by_id_without_tenant.return_value = _make_workflow_run()
monkeypatch.setattr(TraceTask, "_get_workflow_run_repo", classmethod(lambda cls: repo))
monkeypatch.setattr("core.ops.ops_trace_manager.Session", _DummySession)
monkeypatch.setattr("core.ops.ops_trace_manager.db", SimpleNamespace(engine=MagicMock()))
monkeypatch.setattr("core.telemetry.gateway.is_enterprise_telemetry_enabled", lambda: False)
_DummySession.scalar_values = [None, None]
task = TraceTask(
TraceTaskName.WORKFLOW_TRACE,
workflow_execution=SimpleNamespace(id_="run-1", total_tokens=0),
conversation_id="conv-1",
user_id="user-1",
trace_session_id="session-1",
)
trace_info = task.workflow_trace(workflow_run_id="run-1", conversation_id="conv-1", user_id="user-1")
assert task.kwargs["trace_session_id"] == "session-1"
assert trace_info.metadata["trace_session_id"] == "session-1"
def test_message_trace_metadata_includes_trace_session_id(monkeypatch):
db_session = MagicMock()
db_session.scalars.return_value.all.return_value = ["chat"]
db_session.scalar.return_value = None
monkeypatch.setattr(
"core.ops.ops_trace_manager.db",
SimpleNamespace(engine=MagicMock(), session=db_session),
)
monkeypatch.setattr("core.ops.ops_trace_manager.Session", _DummySession)
monkeypatch.setattr("core.ops.ops_trace_manager.get_message_data", lambda message_id: _make_message_data())
monkeypatch.setattr("core.telemetry.gateway.is_enterprise_telemetry_enabled", lambda: False)
_DummySession.scalar_values = ["tenant-1"]
task = TraceTask(
TraceTaskName.MESSAGE_TRACE,
message_id="message-1",
trace_session_id="session-1",
)
trace_info = task.message_trace(message_id="message-1", **task.kwargs)
assert task.kwargs["trace_session_id"] == "session-1"
assert trace_info.metadata["trace_session_id"] == "session-1"

View File

@ -174,6 +174,36 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pyt
}
def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.MonkeyPatch):
"""Ensure nested workflows inherit the parent observability session ID."""
tool = _build_tool()
tool.entity.parameters = [
ToolParameter.get_simple_instance(
name="trace_session_id",
llm_description="User workflow input",
typ=ToolParameter.ToolParameterType.STRING,
required=False,
),
]
tool.set_trace_session_id("session-1")
monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None)
mock_user = Mock()
monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user)
generate_mock = MagicMock(return_value={"data": {}})
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke("test_user", {"trace_session_id": "user-input-session"}))
call_kwargs = generate_mock.call_args.kwargs
assert call_kwargs["args"]["inputs"]["trace_session_id"] == "user-input-session"
assert call_kwargs["args"]["trace_session_id"] == "session-1"
def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch):
"""Ensure private trace context does not overwrite same-named workflow inputs."""
tool = _build_tool()
@ -250,6 +280,28 @@ def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.Monkey
assert "parent_trace_context" not in call_kwargs["args"]
def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatch):
"""Ensure reused WorkflowTool instances do not keep stale trace session IDs."""
tool = _build_tool()
tool.set_trace_session_id("session-1")
tool.clear_trace_session_id()
monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None)
mock_user = Mock()
monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user)
generate_mock = MagicMock(return_value={"data": {}})
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
list(tool.invoke("test_user", {}))
call_kwargs = generate_mock.call_args.kwargs
assert "trace_session_id" not in call_kwargs["args"]
@pytest.mark.parametrize(
"runtime_parameters",
[

View File

@ -187,6 +187,44 @@ def test_get_runtime_stores_parent_trace_context_for_workflow_tools(
assert workflow_runtime.runtime.runtime_parameters == {}
def test_get_runtime_stores_trace_session_id_for_workflow_tools(
runtime: DifyToolNodeRuntime,
) -> None:
variable_pool: VariablePool = build_test_variable_pool(
variables=build_system_variables(
conversation_id="conversation-id",
workflow_execution_id="workflow-run-id",
)
)
workflow_runtime = MagicMock()
workflow_runtime.runtime.runtime_parameters = {}
runtime._run_context.trace_session_id = "session-1"
node_data = ToolNodeData.model_validate(
{
"type": "tool",
"title": "Tool",
"provider_id": "provider",
"provider_type": ToolProviderType.WORKFLOW,
"provider_name": "provider",
"tool_name": "lookup",
"tool_label": "Lookup",
"tool_configurations": {},
"tool_parameters": {},
}
)
with patch.object(ToolManager, "get_workflow_tool_runtime", return_value=workflow_runtime):
tool_runtime = runtime.get_runtime(
node_id="node-id",
node_data=node_data,
variable_pool=variable_pool,
node_execution_id="node-execution-id",
)
assert tool_runtime.raw.trace_session_id == "session-1"
assert workflow_runtime.runtime.runtime_parameters == {}
def test_get_runtime_leaves_non_workflow_tool_runtime_parameters_unchanged(
runtime: DifyToolNodeRuntime,
) -> None:

View File

@ -470,6 +470,46 @@ def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools
get_runtime.assert_called_once()
def test_dify_tool_node_runtime_stores_trace_session_id_for_workflow_tools(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={}))
get_runtime = MagicMock(return_value=runtime_tool)
monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime)
monkeypatch.setattr(
node_runtime,
"get_system_text",
lambda _pool, key: (
"outer-workflow-run-id" if key == node_runtime.SystemVariableKey.WORKFLOW_EXECUTION_ID else None
),
)
run_context = _build_run_context()
run_context[DIFY_RUN_CONTEXT_KEY].trace_session_id = "session-1"
runtime = node_runtime.DifyToolNodeRuntime(run_context)
node_data = ToolNodeData(
title="Workflow Tool Node",
desc=None,
provider_id="workflow-provider-id",
provider_type=ToolProviderType.WORKFLOW,
provider_name="workflow-provider",
tool_name="workflow-tool",
tool_label="Workflow Tool",
tool_configurations={},
tool_parameters={},
)
handle = runtime.get_runtime(
node_id="tool-node",
node_data=node_data,
variable_pool=object(),
node_execution_id="node-execution-id",
)
assert handle.raw.trace_session_id == "session-1"
assert runtime_tool.runtime.runtime_parameters == {}
def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_workflow_tools(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View File

@ -57,6 +57,7 @@ export type ChatRequestPayload = {
query: string
response_mode?: 'blocking' | 'streaming' | null
retriever_from?: string
trace_session_id?: string | null
workflow_id?: string | null
}
@ -107,6 +108,7 @@ export type CompletionRequestPayload = {
query?: string
response_mode?: 'blocking' | 'streaming' | null
retriever_from?: string
trace_session_id?: string | null
}
export type Condition = {
@ -995,6 +997,7 @@ export type WorkflowRunPayload = {
[key: string]: unknown
}
response_mode?: 'blocking' | 'streaming' | null
trace_session_id?: string | null
}
export type WorkflowRunResponse = {

View File

@ -72,6 +72,7 @@ export const zChatRequestPayload = z.object({
query: z.string(),
response_mode: z.enum(['blocking', 'streaming']).nullish(),
retriever_from: z.string().optional().default('dev'),
trace_session_id: z.string().nullish(),
workflow_id: z.string().nullish(),
})
@ -139,6 +140,7 @@ export const zCompletionRequestPayload = z.object({
query: z.string().optional().default(''),
response_mode: z.enum(['blocking', 'streaming']).nullish(),
retriever_from: z.string().optional().default('dev'),
trace_session_id: z.string().nullish(),
})
/**
@ -1351,6 +1353,7 @@ export const zWorkflowRunPayload = z.object({
files: z.array(z.record(z.string(), z.unknown())).nullish(),
inputs: z.record(z.string(), z.unknown()),
response_mode: z.enum(['blocking', 'streaming']).nullish(),
trace_session_id: z.string().nullish(),
})
/**

View File

@ -66,6 +66,12 @@ The text generation application offers non-session support and is ideal for tran
- `url` File URL. (Only when transfer method is `remote_url`).
- `upload_file_id` Upload file ID. (Only when transfer method is `local_file`).
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
(Optional) Trace session ID for observability grouping. Tracing providers that support session grouping can use this value as the exported session identifier. It does not change conversation_id, workflow_run_id, trace_id, or span relationships. Supports the following three ways to pass, in order of priority:<br/>
- Header: via HTTP Header <code>X-Trace-Session-Id</code>, highest priority.<br/>
- Query parameter: via URL query parameter <code>trace_session_id</code>.<br/>
- Request Body: via request body field <code>trace_session_id</code> (i.e., this field).<br/>
</Property>
</Properties>
### Response

View File

@ -66,6 +66,12 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- `url` ファイルのURL。転送方法が `remote_url` の場合のみ)。
- `upload_file_id` アップロードされたファイルID。転送方法が `local_file` の場合のみ)。
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
オプション可観測性のグルーピングに使用するトレースセッションID。セッションのグルーピングに対応するトレースプロバイダーは、この値をエクスポートされるセッション識別子として使用できます。conversation_id、workflow_run_id、trace_id、または span の関係は変更しません。以下の3つの方法で渡すことができ、優先順位は次のとおりです<br/>
- HeaderHTTPヘッダー <code>X-Trace-Session-Id</code> で渡す(最優先)。<br/>
- クエリパラメータURLクエリパラメータ <code>trace_session_id</code> で渡す。<br/>
- リクエストボディ:リクエストボディの <code>trace_session_id</code> フィールドで渡す(本フィールド)。<br/>
</Property>
</Properties>
### レスポンス

View File

@ -64,6 +64,12 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- `url` 文件地址。(仅当传递方式为 `remote_url` 时)。
- `upload_file_id` 上传文件 ID。仅当传递方式为 `local_file `时)。
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
(选填)用于可观测性分组的链路会话 ID。支持会话分组的追踪提供商可将该值用作导出的会话标识。它不会改变 conversation_id、workflow_run_id、trace_id 或 span 关系。支持以下三种方式传递,具体优先级依次为:<br/>
- Header通过 HTTP Header <code>X-Trace-Session-Id</code> 传递,优先级最高。<br/>
- Query 参数:通过 URL 查询参数 <code>trace_session_id</code> 传递。<br/>
- Request Body通过请求体字段 <code>trace_session_id</code> 传递(即本字段)。<br/>
</Property>
</Properties>
### Response

View File

@ -83,6 +83,12 @@ Chat applications support session persistence, allowing previous chat history to
- Query parameter: via URL query parameter <code>trace_id</code>.<br/>
- Request Body: via request body field <code>trace_id</code> (i.e., this field).<br/>
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
(Optional) Trace session ID for observability grouping. Tracing providers that support session grouping can use this value as the exported session identifier. It does not change conversation_id, workflow_run_id, trace_id, or span relationships. Supports the following three ways to pass, in order of priority:<br/>
- Header: via HTTP Header <code>X-Trace-Session-Id</code>, highest priority.<br/>
- Query parameter: via URL query parameter <code>trace_session_id</code>.<br/>
- Request Body: via request body field <code>trace_session_id</code> (i.e., this field).<br/>
</Property>
</Properties>
### Response

View File

@ -83,6 +83,12 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- クエリパラメータURLクエリパラメータ <code>trace_id</code> で渡す。<br/>
- リクエストボディ:リクエストボディの <code>trace_id</code> フィールドで渡す(本フィールド)。<br/>
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
オプション可観測性のグルーピングに使用するトレースセッションID。セッションのグルーピングに対応するトレースプロバイダーは、この値をエクスポートされるセッション識別子として使用できます。conversation_id、workflow_run_id、trace_id、または span の関係は変更しません。以下の3つの方法で渡すことができ、優先順位は次のとおりです<br/>
- HeaderHTTPヘッダー <code>X-Trace-Session-Id</code> で渡す(最優先)。<br/>
- クエリパラメータURLクエリパラメータ <code>trace_session_id</code> で渡す。<br/>
- リクエストボディ:リクエストボディの <code>trace_session_id</code> フィールドで渡す(本フィールド)。<br/>
</Property>
</Properties>
### 応答

View File

@ -80,6 +80,12 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- Query 参数:通过 URL 查询参数 <code>trace_id</code> 传递。<br/>
- Request Body通过请求体字段 <code>trace_id</code> 传递(即本字段)。<br/>
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
(选填)用于可观测性分组的链路会话 ID。支持会话分组的追踪提供商可将该值用作导出的会话标识。它不会改变 conversation_id、workflow_run_id、trace_id 或 span 关系。支持以下三种方式传递,具体优先级依次为:<br/>
- Header通过 HTTP Header <code>X-Trace-Session-Id</code> 传递,优先级最高。<br/>
- Query 参数:通过 URL 查询参数 <code>trace_session_id</code> 传递。<br/>
- Request Body通过请求体字段 <code>trace_session_id</code> 传递(即本字段)。<br/>
</Property>
</Properties>
### Response

View File

@ -83,6 +83,12 @@ Chat applications support session persistence, allowing previous chat history to
- Query parameter: via URL query parameter <code>trace_id</code>.<br/>
- Request Body: via request body field <code>trace_id</code> (i.e., this field).<br/>
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
(Optional) Trace session ID for observability grouping. Tracing providers that support session grouping can use this value as the exported session identifier. It does not change conversation_id, workflow_run_id, trace_id, or span relationships. Supports the following three ways to pass, in order of priority:<br/>
- Header: via HTTP Header <code>X-Trace-Session-Id</code>, highest priority.<br/>
- Query parameter: via URL query parameter <code>trace_session_id</code>.<br/>
- Request Body: via request body field <code>trace_session_id</code> (i.e., this field).<br/>
</Property>
</Properties>
### Response

View File

@ -83,6 +83,12 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- クエリパラメータURLクエリパラメータ <code>trace_id</code> で渡す。<br/>
- リクエストボディ:リクエストボディの <code>trace_id</code> フィールドで渡す(本フィールド)。<br/>
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
オプション可観測性のグルーピングに使用するトレースセッションID。セッションのグルーピングに対応するトレースプロバイダーは、この値をエクスポートされるセッション識別子として使用できます。conversation_id、workflow_run_id、trace_id、または span の関係は変更しません。以下の3つの方法で渡すことができ、優先順位は次のとおりです<br/>
- HeaderHTTPヘッダー <code>X-Trace-Session-Id</code> で渡す(最優先)。<br/>
- クエリパラメータURLクエリパラメータ <code>trace_session_id</code> で渡す。<br/>
- リクエストボディ:リクエストボディの <code>trace_session_id</code> フィールドで渡す(本フィールド)。<br/>
</Property>
</Properties>
### 応答

View File

@ -81,6 +81,12 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- Query 参数:通过 URL 查询参数 <code>trace_id</code> 传递。<br/>
- Request Body通过请求体字段 <code>trace_id</code> 传递(即本字段)。<br/>
</Property>
<Property name='trace_session_id' type='string' key='trace_session_id'>
(选填)用于可观测性分组的链路会话 ID。支持会话分组的追踪提供商可将该值用作导出的会话标识。它不会改变 conversation_id、workflow_run_id、trace_id 或 span 关系。支持以下三种方式传递,具体优先级依次为:<br/>
- Header通过 HTTP Header <code>X-Trace-Session-Id</code> 传递,优先级最高。<br/>
- Query 参数:通过 URL 查询参数 <code>trace_session_id</code> 传递。<br/>
- Request Body通过请求体字段 <code>trace_session_id</code> 传递(即本字段)。<br/>
</Property>
</Properties>
### Response

View File

@ -66,6 +66,11 @@ Workflow applications offers non-session support and is ideal for translation, a
1. Header: via HTTP Header `X-Trace-Id`, highest priority.
2. Query parameter: via URL query parameter `trace_id`.
3. Request Body: via request body field `trace_id` (i.e., this field).
- `trace_session_id` (string) Optional
Trace session ID for observability grouping. Tracing providers that support session grouping can use this value as the exported session identifier. It does not change conversation_id, workflow_run_id, trace_id, or span relationships. Supports the following three ways to pass, in order of priority:
1. Header: via HTTP Header `X-Trace-Session-Id`, highest priority.
2. Query parameter: via URL query parameter `trace_session_id`.
3. Request Body: via request body field `trace_session_id` (i.e., this field).
### Response
When `response_mode` is `blocking`, return a CompletionResponse object.
@ -680,6 +685,11 @@ Workflow applications offers non-session support and is ideal for translation, a
1. Header: via HTTP Header `X-Trace-Id`, highest priority.
2. Query parameter: via URL query parameter `trace_id`.
3. Request Body: via request body field `trace_id` (i.e., this field).
- `trace_session_id` (string) Optional
Trace session ID for observability grouping. Tracing providers that support session grouping can use this value as the exported session identifier. It does not change conversation_id, workflow_run_id, trace_id, or span relationships. Supports the following three ways to pass, in order of priority:
1. Header: via HTTP Header `X-Trace-Session-Id`, highest priority.
2. Query parameter: via URL query parameter `trace_session_id`.
3. Request Body: via request body field `trace_session_id` (i.e., this field).
### Response
When `response_mode` is `blocking`, return a CompletionResponse object.

View File

@ -65,6 +65,11 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
1. HeaderHTTPヘッダー `X-Trace-Id` で渡す(最優先)。
2. クエリパラメータURLクエリパラメータ `trace_id` で渡す。
3. リクエストボディ:リクエストボディの `trace_id` フィールドで渡す(本フィールド)。
- `trace_session_id` (string) オプション
可観測性のグルーピングに使用するトレースセッションID。セッションのグルーピングに対応するトレースプロバイダーは、この値をエクスポートされるセッション識別子として使用できます。conversation_id、workflow_run_id、trace_id、または span の関係は変更しません。以下の3つの方法で渡すことができ、優先順位は次のとおりです
1. ヘッダーHTTP ヘッダー `X-Trace-Session-Id` で渡すことを推奨、最高優先度。
2. クエリパラメータURL クエリパラメータ `trace_session_id` で渡す。
3. リクエストボディ:リクエストボディフィールド `trace_session_id` で渡す(つまり、このフィールド)。
### 応答
@ -675,6 +680,11 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
1. ヘッダーHTTP ヘッダー `X-Trace-Id` で渡すことを推奨、最高優先度。
2. クエリパラメータURL クエリパラメータ `trace_id` で渡す。
3. リクエストボディ:リクエストボディフィールド `trace_id` で渡す(つまり、このフィールド)。
- `trace_session_id` (string) オプション
可観測性のグルーピングに使用するトレースセッションID。セッションのグルーピングに対応するトレースプロバイダーは、この値をエクスポートされるセッション識別子として使用できます。conversation_id、workflow_run_id、trace_id、または span の関係は変更しません。以下の3つの方法で渡すことができ、優先順位は以下の通りです
1. ヘッダーHTTP ヘッダー `X-Trace-Session-Id` で渡すことを推奨、最高優先度。
2. クエリパラメータURL クエリパラメータ `trace_session_id` で渡す。
3. リクエストボディ:リクエストボディフィールド `trace_session_id` で渡す(つまり、このフィールド)。
### 応答
`response_mode` が `blocking` の場合、CompletionResponse オブジェクトを返します。

View File

@ -58,6 +58,11 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
1. Header推荐通过 HTTP Header `X-Trace-Id` 传递,优先级最高。
2. Query 参数:通过 URL 查询参数 `trace_id` 传递。
3. Request Body通过请求体字段 `trace_id` 传递(即本字段)。
- `trace_session_id` (string) Optional
用于可观测性分组的链路会话 ID。支持会话分组的追踪提供商可将该值用作导出的会话标识。它不会改变 conversation_id、workflow_run_id、trace_id 或 span 关系。支持以下三种方式传递,具体优先级依次为:
1. Header通过 HTTP Header `X-Trace-Session-Id` 传递,优先级最高。
2. Query 参数:通过 URL 查询参数 `trace_session_id` 传递。
3. Request Body通过请求体字段 `trace_session_id` 传递(即本字段)。
### Response
当 `response_mode` 为 `blocking` 时,返回 CompletionResponse object。
@ -668,6 +673,11 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
1. Header推荐通过 HTTP Header `X-Trace-Id` 传递,优先级最高。
2. Query 参数:通过 URL 查询参数 `trace_id` 传递。
3. Request Body通过请求体字段 `trace_id` 传递(即本字段)。
- `trace_session_id` (string) Optional
用于可观测性分组的链路会话 ID。支持会话分组的追踪提供商可将该值用作导出的会话标识。它不会改变 conversation_id、workflow_run_id、trace_id 或 span 关系。支持以下三种方式传递,具体优先级依次为:
1. Header通过 HTTP Header `X-Trace-Session-Id` 传递,优先级最高。
2. Query 参数:通过 URL 查询参数 `trace_session_id` 传递。
3. Request Body通过请求体字段 `trace_session_id` 传递(即本字段)。
### Response
当 `response_mode` 为 `blocking` 时,返回 CompletionResponse object。