mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(trace): add provider-neutral unified tracing (#39451)
Co-authored-by: EvoX <evox@evomap.ai>
This commit is contained in:
1 parent
23150c2319
commit
3a8fbcbd02
37 files changed
+3496
-58
No files matched your search
@@ -229,7 +229,9 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
||||
:return:
|
||||
"""
|
||||
self._conversation_name_generate_thread = self._message_cycle_manager.generate_conversation_name(
|
||||
conversation_id=self._conversation_id, query=self._application_generate_entity.query
|
||||
conversation_id=self._conversation_id,
|
||||
query=self._application_generate_entity.query,
|
||||
message_id=self._message_id,
|
||||
)
|
||||
|
||||
generator = self._wrapper_process_stream_response(trace_manager=self._application_generate_entity.trace_manager)
|
||||
|
||||
@@ -125,7 +125,9 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
if self._application_generate_entity.app_config.app_mode != AppMode.COMPLETION:
|
||||
# start generate conversation name thread
|
||||
self._conversation_name_generate_thread = self._message_cycle_manager.generate_conversation_name(
|
||||
conversation_id=self._conversation_id, query=self._application_generate_entity.query
|
||||
conversation_id=self._conversation_id,
|
||||
query=self._application_generate_entity.query,
|
||||
message_id=self._message_id,
|
||||
)
|
||||
|
||||
generator = self._wrapper_process_stream_response(trace_manager=self._application_generate_entity.trace_manager)
|
||||
|
||||
@@ -75,7 +75,9 @@ class MessageCycleManager:
|
||||
|
||||
return StreamEvent.MESSAGE
|
||||
|
||||
def generate_conversation_name(self, *, conversation_id: str, query: str) -> Thread | None:
|
||||
def generate_conversation_name(
|
||||
self, *, conversation_id: str, query: str, message_id: str | None = None
|
||||
) -> Thread | None:
|
||||
"""
|
||||
Generate conversation name.
|
||||
:param conversation_id: conversation id
|
||||
@@ -100,6 +102,7 @@ class MessageCycleManager:
|
||||
"flask_app": current_app._get_current_object(), # type: ignore
|
||||
"conversation_id": conversation_id,
|
||||
"query": query,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
thread.daemon = True
|
||||
@@ -110,7 +113,13 @@ class MessageCycleManager:
|
||||
|
||||
return thread
|
||||
|
||||
def _generate_conversation_name_worker(self, flask_app: Flask, conversation_id: str, query: str):
|
||||
def _generate_conversation_name_worker(
|
||||
self,
|
||||
flask_app: Flask,
|
||||
conversation_id: str,
|
||||
query: str,
|
||||
message_id: str | None = None,
|
||||
):
|
||||
with flask_app.app_context():
|
||||
with session_factory.create_session() as session:
|
||||
# get conversation and message
|
||||
@@ -135,7 +144,11 @@ class MessageCycleManager:
|
||||
else:
|
||||
try:
|
||||
name = LLMGenerator.generate_conversation_name(
|
||||
app_model.tenant_id, query, conversation_id, conversation.app_id
|
||||
app_model.tenant_id,
|
||||
query,
|
||||
conversation_id,
|
||||
conversation.app_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
redis_client.setex(cache_key, 3600, name)
|
||||
except Exception:
|
||||
|
||||
@@ -188,7 +188,12 @@ class LLMGenerator:
|
||||
|
||||
@classmethod
|
||||
def generate_conversation_name(
|
||||
cls, tenant_id: str, query, conversation_id: str | None = None, app_id: str | None = None
|
||||
cls,
|
||||
tenant_id: str,
|
||||
query,
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
):
|
||||
prompt = CONVERSATION_TITLE_PROMPT
|
||||
|
||||
@@ -239,6 +244,7 @@ class LLMGenerator:
|
||||
TraceTask(
|
||||
TraceTaskName.GENERATE_NAME_TRACE,
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
generate_conversation_name=name,
|
||||
inputs=prompt,
|
||||
timer=timer,
|
||||
|
||||
@@ -54,3 +54,11 @@ class BaseTracingConfig(BaseModel):
|
||||
|
||||
OPS_FILE_PATH = "ops_trace/"
|
||||
OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE"
|
||||
|
||||
|
||||
def ops_trace_payload_path(app_id: str, file_id: str) -> str:
|
||||
return f"{OPS_FILE_PATH}{app_id}/{file_id}.json"
|
||||
|
||||
|
||||
def workflow_final_trace_file_id(workflow_run_id: str) -> str:
|
||||
return f"workflow-final-{workflow_run_id}"
|
||||
@@ -9,6 +9,7 @@ from core.helper.trace_id_helper import ParentTraceContext
|
||||
|
||||
|
||||
class BaseTraceInfo(BaseModel):
|
||||
operation_id: str | None = None
|
||||
message_id: str | None = None
|
||||
message_data: Any | None = None
|
||||
inputs: Union[str, dict[str, Any], list[Any]] | None = None
|
||||
|
||||
@@ -9,6 +9,14 @@ class RetryableTraceDispatchError(RuntimeError):
|
||||
"""Base class for transient trace dispatch failures that Celery may retry."""
|
||||
|
||||
|
||||
class TraceParentContextAccessError(RetryableTraceDispatchError):
|
||||
"""Raised when unified parent context storage is temporarily unavailable."""
|
||||
|
||||
|
||||
class InvalidTraceParentContextError(RuntimeError):
|
||||
"""Raised when stored unified parent context cannot be safely restored."""
|
||||
|
||||
|
||||
class PendingTraceParentContextError(RetryableTraceDispatchError):
|
||||
"""Raised when a nested trace arrives before its parent span context is available."""
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.ops.entities.config_entity import (
|
||||
OPS_FILE_PATH,
|
||||
BaseTracingConfig,
|
||||
TracingProviderEnum,
|
||||
ops_trace_payload_path,
|
||||
)
|
||||
from core.ops.entities.trace_entity import (
|
||||
BaseTraceInfo,
|
||||
DatasetRetrievalTraceInfo,
|
||||
DraftNodeExecutionTrace,
|
||||
GenerateNameTraceInfo,
|
||||
@@ -37,6 +39,7 @@ from core.ops.entities.trace_entity import (
|
||||
WorkflowNodeTraceInfo,
|
||||
WorkflowTraceInfo,
|
||||
)
|
||||
from core.ops.unified_trace.registry import UnifiedProviderConfigEntry, unified_provider_config_map
|
||||
from core.ops.utils import JSON_DICT_ADAPTER, get_message_data
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_storage import storage
|
||||
@@ -344,6 +347,22 @@ class OpsTraceManager:
|
||||
decrypted_configs_cache: LRUCache = LRUCache(maxsize=128)
|
||||
_decryption_cache_lock = threading.RLock()
|
||||
|
||||
@classmethod
|
||||
def _get_dispatch_entry(
|
||||
cls, tracing_provider: str
|
||||
) -> tuple[str, TracingProviderConfigEntry | UnifiedProviderConfigEntry]:
|
||||
"""Select one tracing mode before constructing an instance.
|
||||
|
||||
Registered unified providers never fall back after construction or dispatch starts.
|
||||
Unregistered providers continue through the legacy registry.
|
||||
"""
|
||||
if dify_config.OPS_TRACE_UNIFIED_ENABLED:
|
||||
try:
|
||||
return "unified", unified_provider_config_map[tracing_provider]
|
||||
except KeyError:
|
||||
pass
|
||||
return "legacy", provider_config_map[tracing_provider]
|
||||
|
||||
@classmethod
|
||||
def encrypt_tracing_config(
|
||||
cls, tenant_id: str, tracing_provider: str, tracing_config: dict[str, Any], current_trace_config=None
|
||||
@@ -526,17 +545,16 @@ class OpsTraceManager:
|
||||
if not decrypt_trace_config:
|
||||
return None
|
||||
|
||||
trace_instance, config_class = (
|
||||
provider_config_map[tracing_provider]["trace_instance"],
|
||||
provider_config_map[tracing_provider]["config_class"],
|
||||
)
|
||||
decrypt_trace_config_key = json.dumps(decrypt_trace_config, sort_keys=True)
|
||||
tracing_instance = cls.ops_trace_instances_cache.get(decrypt_trace_config_key)
|
||||
mode, dispatch_entry = cls._get_dispatch_entry(tracing_provider)
|
||||
trace_instance = dispatch_entry["trace_instance"]
|
||||
config_class = dispatch_entry["config_class"]
|
||||
cache_key = (mode, tracing_provider, json.dumps(decrypt_trace_config, sort_keys=True))
|
||||
tracing_instance = cls.ops_trace_instances_cache.get(cache_key)
|
||||
if tracing_instance is None:
|
||||
# create new tracing_instance and update the cache if it absent
|
||||
# The mode is part of the key so unified and legacy clients never share mutable SDK state.
|
||||
tracing_instance = trace_instance(config_class(**decrypt_trace_config))
|
||||
cls.ops_trace_instances_cache[decrypt_trace_config_key] = tracing_instance
|
||||
logger.info("new tracing_instance for app_id: %s", app_id)
|
||||
cls.ops_trace_instances_cache[cache_key] = tracing_instance
|
||||
logger.info("new %s tracing_instance for app_id: %s", mode, app_id)
|
||||
return tracing_instance
|
||||
|
||||
@classmethod
|
||||
@@ -739,6 +757,8 @@ class TraceTask:
|
||||
trace_type: Any,
|
||||
message_id: str | None = None,
|
||||
workflow_execution: "WorkflowExecution | None" = None,
|
||||
workflow_run_id: str | None = None,
|
||||
workflow_total_tokens: int | None = None,
|
||||
conversation_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
timer: Any | None = None,
|
||||
@@ -746,8 +766,8 @@ class TraceTask:
|
||||
):
|
||||
self.trace_type = trace_type
|
||||
self.message_id = message_id
|
||||
self.workflow_run_id = workflow_execution.id_ if workflow_execution else None
|
||||
self.workflow_total_tokens: int | None = workflow_execution.total_tokens if workflow_execution else None
|
||||
self.workflow_run_id = workflow_execution.id_ if workflow_execution else workflow_run_id
|
||||
self.workflow_total_tokens = workflow_execution.total_tokens if workflow_execution else workflow_total_tokens
|
||||
self.conversation_id = conversation_id
|
||||
self.user_id = user_id
|
||||
self.timer = timer
|
||||
@@ -1275,6 +1295,7 @@ class TraceTask:
|
||||
|
||||
generate_name_trace_info = GenerateNameTraceInfo(
|
||||
trace_id=self.trace_id,
|
||||
message_id=self.message_id,
|
||||
conversation_id=conversation_id,
|
||||
inputs=inputs,
|
||||
outputs=generate_conversation_name,
|
||||
@@ -1539,30 +1560,57 @@ class TraceQueueManager:
|
||||
trace_manager_timer.daemon = False
|
||||
trace_manager_timer.start()
|
||||
|
||||
def _resolve_storage_id(self, task: TraceTask) -> str | None:
|
||||
storage_id = task.app_id
|
||||
if storage_id is not None:
|
||||
return storage_id
|
||||
|
||||
tenant_id = task.kwargs.get("tenant_id")
|
||||
if tenant_id:
|
||||
return f"tenant-{tenant_id}"
|
||||
|
||||
logger.warning("Skipping trace without app_id or tenant_id, trace_type: %s", task.trace_type)
|
||||
return None
|
||||
|
||||
def persist_trace_task(self, task: TraceTask, *, file_id: str | None = None) -> dict[str, str] | None:
|
||||
if not (self._enterprise_telemetry_enabled or self.trace_instance):
|
||||
return None
|
||||
|
||||
task.app_id = self.app_id
|
||||
storage_id = self._resolve_storage_id(task)
|
||||
if storage_id is None:
|
||||
return None
|
||||
|
||||
resolved_file_id = file_id or uuid4().hex
|
||||
trace_info = task.execute()
|
||||
if isinstance(trace_info, BaseTraceInfo) and trace_info.operation_id is None:
|
||||
trace_info = trace_info.model_copy(update={"operation_id": str(uuid4())})
|
||||
task_data = TaskData(
|
||||
app_id=storage_id,
|
||||
trace_info_type=type(trace_info).__name__,
|
||||
trace_info=trace_info.model_dump() if trace_info else None,
|
||||
)
|
||||
storage.save(
|
||||
ops_trace_payload_path(storage_id, resolved_file_id),
|
||||
task_data.model_dump_json().encode("utf-8"),
|
||||
)
|
||||
return {"file_id": resolved_file_id, "app_id": storage_id}
|
||||
|
||||
def enqueue_persisted_trace(self, file_info: dict[str, str]) -> None:
|
||||
process_trace_tasks.apply_async(
|
||||
args=(file_info,),
|
||||
retry=True,
|
||||
retry_policy={
|
||||
"max_retries": 3,
|
||||
"interval_start": 0,
|
||||
"interval_step": 1,
|
||||
"interval_max": 2,
|
||||
},
|
||||
)
|
||||
|
||||
def send_to_celery(self, tasks: list[TraceTask]):
|
||||
with self.flask_app.app_context():
|
||||
for task in tasks:
|
||||
storage_id = task.app_id
|
||||
if storage_id is None:
|
||||
tenant_id = task.kwargs.get("tenant_id")
|
||||
if tenant_id:
|
||||
storage_id = f"tenant-{tenant_id}"
|
||||
else:
|
||||
logger.warning("Skipping trace without app_id or tenant_id, trace_type: %s", task.trace_type)
|
||||
continue
|
||||
|
||||
file_id = uuid4().hex
|
||||
trace_info = task.execute()
|
||||
|
||||
task_data = TaskData(
|
||||
app_id=storage_id,
|
||||
trace_info_type=type(trace_info).__name__,
|
||||
trace_info=trace_info.model_dump() if trace_info else None,
|
||||
)
|
||||
file_path = f"{OPS_FILE_PATH}{storage_id}/{file_id}.json"
|
||||
storage.save(file_path, task_data.model_dump_json().encode("utf-8"))
|
||||
file_info = {
|
||||
"file_id": file_id,
|
||||
"app_id": storage_id,
|
||||
}
|
||||
process_trace_tasks.delay(file_info) # type: ignore
|
||||
file_info = self.persist_trace_task(task)
|
||||
if file_info is not None:
|
||||
self.enqueue_persisted_trace(file_info)
|
||||
@@ -0,0 +1 @@
|
||||
"""Provider-neutral trace construction and dispatch for opt-in unified tracing."""
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Provider-independent trace entities emitted by the unified trace builder."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
|
||||
|
||||
class CanonicalSpanKind(StrEnum):
|
||||
CHAIN = "chain"
|
||||
LLM = "llm"
|
||||
RETRIEVER = "retriever"
|
||||
TOOL = "tool"
|
||||
AGENT = "agent"
|
||||
|
||||
|
||||
class CanonicalSpanStatus(StrEnum):
|
||||
OK = "ok"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class CanonicalSpan(BaseModel):
|
||||
"""One provider-neutral operation with an explicit parent."""
|
||||
|
||||
id: str
|
||||
parent_id: str | None
|
||||
name: str
|
||||
kind: CanonicalSpanKind
|
||||
start_time: datetime
|
||||
end_time: datetime | None
|
||||
inputs: Any = None
|
||||
outputs: Any = None
|
||||
status: CanonicalSpanStatus
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
synthetic: bool = False
|
||||
can_parent_workflow: bool = False
|
||||
publishes_parent_context: bool = False
|
||||
links: tuple[str, ...] = ()
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class CanonicalTrace(BaseModel):
|
||||
"""A parent-before-child span tree ready for a provider adapter."""
|
||||
|
||||
trace_id: str
|
||||
session_id: str
|
||||
root_span_id: str
|
||||
spans: tuple[CanonicalSpan, ...]
|
||||
external_parent: ParentTraceContext | None = None
|
||||
required_parent_context_id: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_fragment(self) -> Self:
|
||||
if self.external_parent is not None and self.required_parent_context_id is not None:
|
||||
raise ValueError("canonical trace cannot require two external parent modes")
|
||||
|
||||
seen: set[str] = set()
|
||||
root_seen = False
|
||||
for span in self.spans:
|
||||
if span.id in seen:
|
||||
raise ValueError(f"duplicate canonical span id: {span.id}")
|
||||
if span.id == self.root_span_id:
|
||||
root_seen = True
|
||||
if span.parent_id is not None:
|
||||
raise ValueError("canonical trace root cannot have a local parent")
|
||||
elif span.parent_id is None or span.parent_id not in seen:
|
||||
raise ValueError(f"canonical span parent must appear first: {span.id}")
|
||||
seen.add(span.id)
|
||||
|
||||
if not root_seen:
|
||||
raise ValueError("canonical trace root is missing")
|
||||
return self
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Deterministically reconstruct provider-neutral workflow execution hierarchy."""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
|
||||
_WRAPPER_INDEX_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]+$")
|
||||
_WRAPPER_FIELDS: tuple[tuple[Literal["iteration", "loop"], str, str], ...] = (
|
||||
("iteration", "iteration_id", "iteration_index"),
|
||||
("loop", "loop_id", "loop_index"),
|
||||
)
|
||||
|
||||
|
||||
WorkflowExecutionLike = WorkflowNodeExecution
|
||||
|
||||
|
||||
def _read_attribute(value: object, name: str, default: Any = None) -> Any:
|
||||
"""Read fields shared by persisted executions and legacy trace objects."""
|
||||
return getattr(value, name, default) # guard-ignore: no-new-getattr -- supports legacy trace objects
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WrapperKey:
|
||||
kind: Literal["iteration", "loop"]
|
||||
container_execution_id: str
|
||||
index: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WrapperSpec:
|
||||
id: str
|
||||
key: WrapperKey
|
||||
parent_execution_id: str
|
||||
child_execution_ids: frozenset[str]
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
has_error: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowHierarchy:
|
||||
parent_by_execution_id: Mapping[str, str]
|
||||
wrapper_by_child_execution_id: Mapping[str, WrapperSpec]
|
||||
wrappers: tuple[WrapperSpec, ...]
|
||||
|
||||
|
||||
def execution_id(execution: WorkflowExecutionLike) -> str:
|
||||
"""Return the persisted execution identifier used as a canonical span ID."""
|
||||
return str(_read_attribute(execution, "id") or execution.node_execution_id)
|
||||
|
||||
|
||||
def execution_metadata(execution: object) -> Mapping[str, Any]:
|
||||
"""Read metadata from repository models and older trace test doubles."""
|
||||
value = _read_attribute(execution, "execution_metadata_dict")
|
||||
if not isinstance(value, Mapping):
|
||||
value = _read_attribute(execution, "metadata")
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
return {str(key): item for key, item in value.items()}
|
||||
|
||||
|
||||
def _unique_execution_by_node_id(executions: Sequence[WorkflowExecutionLike]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
ambiguous: set[str] = set()
|
||||
for item in executions:
|
||||
node_id = item.node_id
|
||||
if not isinstance(node_id, str) or node_id in ambiguous:
|
||||
continue
|
||||
item_execution_id = execution_id(item)
|
||||
previous = result.get(node_id)
|
||||
if previous is None:
|
||||
result[node_id] = item_execution_id
|
||||
elif previous != item_execution_id:
|
||||
result.pop(node_id, None)
|
||||
ambiguous.add(node_id)
|
||||
return result
|
||||
|
||||
|
||||
def _metadata_or_attr(execution: object, metadata: Mapping[str, Any], key: str) -> Any:
|
||||
value = metadata.get(key)
|
||||
return value if value is not None else _read_attribute(execution, key)
|
||||
|
||||
|
||||
def _normalize_index(value: Any) -> str | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return str(value) if value >= 0 else None
|
||||
if isinstance(value, str) and _WRAPPER_INDEX_PATTERN.fullmatch(value):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _finished_at(execution: WorkflowExecutionLike) -> datetime:
|
||||
explicit_end = _read_attribute(execution, "end_time")
|
||||
if isinstance(explicit_end, datetime):
|
||||
return explicit_end
|
||||
started_at = execution.created_at or _read_attribute(execution, "start_time") or datetime.now()
|
||||
return started_at + timedelta(seconds=execution.elapsed_time or 0)
|
||||
|
||||
|
||||
def _failed(execution: WorkflowExecutionLike) -> bool:
|
||||
status = _read_attribute(execution.status, "value", execution.status)
|
||||
return status == "failed"
|
||||
|
||||
|
||||
def _remove_cycles(parent_by_execution_id: dict[str, str]) -> None:
|
||||
cyclic: set[str] = set()
|
||||
for start in sorted(parent_by_execution_id):
|
||||
path: list[str] = []
|
||||
position: dict[str, int] = {}
|
||||
current = start
|
||||
while current in parent_by_execution_id:
|
||||
if current in position:
|
||||
cyclic.update(path[position[current] :])
|
||||
break
|
||||
position[current] = len(path)
|
||||
path.append(current)
|
||||
current = parent_by_execution_id[current]
|
||||
for execution in cyclic:
|
||||
parent_by_execution_id.pop(execution, None)
|
||||
|
||||
|
||||
def build_workflow_hierarchy(executions: Sequence[WorkflowExecutionLike]) -> WorkflowHierarchy:
|
||||
"""Build stable parents and synthetic loop/iteration wrappers.
|
||||
|
||||
Ambiguous repeated graph node IDs and cyclic predecessor data are dropped
|
||||
rather than guessed. Their spans consequently fall back to the workflow root.
|
||||
"""
|
||||
execution_by_node_id = _unique_execution_by_node_id(executions)
|
||||
parent_by_execution_id: dict[str, str] = {}
|
||||
|
||||
for item in executions:
|
||||
item_execution_id = execution_id(item)
|
||||
predecessor_node_id = item.predecessor_node_id
|
||||
parent_execution_id = (
|
||||
execution_by_node_id.get(predecessor_node_id) if isinstance(predecessor_node_id, str) else None
|
||||
)
|
||||
if parent_execution_id is None:
|
||||
metadata = execution_metadata(item)
|
||||
for structured_key in ("iteration_id", "loop_id"):
|
||||
container_node_id = _metadata_or_attr(item, metadata, structured_key)
|
||||
if isinstance(container_node_id, str):
|
||||
parent_execution_id = execution_by_node_id.get(container_node_id)
|
||||
if parent_execution_id is not None:
|
||||
break
|
||||
if parent_execution_id and parent_execution_id != item_execution_id:
|
||||
parent_by_execution_id[item_execution_id] = parent_execution_id
|
||||
|
||||
_remove_cycles(parent_by_execution_id)
|
||||
|
||||
grouped: dict[WrapperKey, list[WorkflowExecutionLike]] = {}
|
||||
for item in executions:
|
||||
metadata = execution_metadata(item)
|
||||
for kind, container_key, index_key in _WRAPPER_FIELDS:
|
||||
container_node_id = _metadata_or_attr(item, metadata, container_key)
|
||||
index = _normalize_index(_metadata_or_attr(item, metadata, index_key))
|
||||
if not isinstance(container_node_id, str) or index is None:
|
||||
continue
|
||||
container_execution_id = execution_by_node_id.get(container_node_id)
|
||||
if container_execution_id is None or container_execution_id == execution_id(item):
|
||||
continue
|
||||
wrapper_key = WrapperKey(kind=kind, container_execution_id=container_execution_id, index=index)
|
||||
grouped.setdefault(wrapper_key, []).append(item)
|
||||
break
|
||||
|
||||
wrappers: list[WrapperSpec] = []
|
||||
wrapper_by_child_execution_id: dict[str, WrapperSpec] = {}
|
||||
for wrapper_key in sorted(grouped, key=lambda item: (item.kind, item.container_execution_id, item.index)):
|
||||
children = grouped[wrapper_key]
|
||||
child_ids = frozenset(execution_id(item) for item in children)
|
||||
wrapper = WrapperSpec(
|
||||
id=f"{wrapper_key.kind}:{wrapper_key.container_execution_id}:{wrapper_key.index}",
|
||||
key=wrapper_key,
|
||||
parent_execution_id=wrapper_key.container_execution_id,
|
||||
child_execution_ids=child_ids,
|
||||
start_time=min(item.created_at or datetime.now() for item in children),
|
||||
end_time=max(_finished_at(item) for item in children),
|
||||
has_error=any(_failed(item) for item in children),
|
||||
)
|
||||
wrappers.append(wrapper)
|
||||
for child_id in child_ids:
|
||||
wrapper_by_child_execution_id[child_id] = wrapper
|
||||
parent_by_execution_id[child_id] = wrapper.id
|
||||
|
||||
return WorkflowHierarchy(
|
||||
parent_by_execution_id=parent_by_execution_id,
|
||||
wrapper_by_child_execution_id=wrapper_by_child_execution_id,
|
||||
wrappers=tuple(wrappers),
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Coordinate nested-workflow provider parent contexts through Redis.
|
||||
|
||||
The coordinator owns storage, compatibility decisions, validation, and retry
|
||||
signals. Provider adapters only create and consume their opaque context fields.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.ops.exceptions import (
|
||||
InvalidTraceParentContextError,
|
||||
PendingTraceParentContextError,
|
||||
TraceParentContextAccessError,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from models.model import App, TraceAppConfig
|
||||
from models.workflow import WorkflowRun
|
||||
|
||||
_PARENT_CONTEXT_KEY_PREFIX = "trace:unified:parent:"
|
||||
|
||||
|
||||
class RedisParentContextStore(Protocol):
|
||||
def setex(self, name: str, time: int, value: str) -> object:
|
||||
raise NotImplementedError
|
||||
|
||||
def get(self, name: str) -> bytes | str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ProviderParentContext(BaseModel):
|
||||
"""Versioned envelope containing the minimum provider restoration state."""
|
||||
|
||||
version: Literal[1] = 1
|
||||
provider: str
|
||||
scope: str
|
||||
trace_id: str
|
||||
parent_id: str
|
||||
provider_context: dict[str, str]
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParentDestination:
|
||||
provider: str
|
||||
scope: str
|
||||
unified: bool
|
||||
|
||||
|
||||
class ParentResolutionKind(StrEnum):
|
||||
RESTORED = "restored"
|
||||
LINKED_ROOT = "linked_root"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParentResolution:
|
||||
kind: ParentResolutionKind
|
||||
context: ProviderParentContext | None = None
|
||||
linked_parent: ParentTraceContext | None = None
|
||||
|
||||
@classmethod
|
||||
def restored(cls, context: ProviderParentContext) -> "ParentResolution":
|
||||
return cls(kind=ParentResolutionKind.RESTORED, context=context)
|
||||
|
||||
@classmethod
|
||||
def linked_root(cls, parent: ParentTraceContext) -> "ParentResolution":
|
||||
return cls(kind=ParentResolutionKind.LINKED_ROOT, linked_parent=parent)
|
||||
|
||||
|
||||
ParentDestinationResolver = Callable[[str], ParentDestination | None]
|
||||
|
||||
|
||||
def destination_scope(provider: str, endpoint: str, project: str) -> str:
|
||||
"""Return a stable non-secret fingerprint for a provider destination."""
|
||||
value = f"{provider}\0{endpoint.rstrip('/')}\0{project}"
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def parent_destination_from_config(
|
||||
provider: str,
|
||||
tracing_config: Mapping[str, object],
|
||||
*,
|
||||
unified: bool,
|
||||
) -> ParentDestination:
|
||||
"""Build destination compatibility from non-secret persisted fields."""
|
||||
endpoint = tracing_config.get("endpoint")
|
||||
project = tracing_config.get("project")
|
||||
return ParentDestination(
|
||||
provider=provider,
|
||||
scope=destination_scope(
|
||||
provider,
|
||||
endpoint if isinstance(endpoint, str) else "",
|
||||
project if isinstance(project, str) else "",
|
||||
),
|
||||
unified=unified,
|
||||
)
|
||||
|
||||
|
||||
def resolve_parent_destination(parent_workflow_run_id: str) -> ParentDestination | None:
|
||||
"""Resolve whether a parent workflow can publish compatible unified context."""
|
||||
with Session(db.engine) as session:
|
||||
workflow_run = session.get(WorkflowRun, parent_workflow_run_id)
|
||||
if workflow_run is None:
|
||||
return None
|
||||
app = session.get(App, workflow_run.app_id)
|
||||
if app is None or not app.tracing:
|
||||
return None
|
||||
try:
|
||||
app_tracing = json.loads(app.tracing)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(app_tracing, Mapping) or not app_tracing.get("enabled"):
|
||||
return None
|
||||
provider = app_tracing.get("tracing_provider")
|
||||
if not isinstance(provider, str):
|
||||
return None
|
||||
trace_config = session.scalar(
|
||||
select(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app.id, TraceAppConfig.tracing_provider == provider)
|
||||
.limit(1)
|
||||
)
|
||||
if trace_config is None or not isinstance(trace_config.tracing_config, Mapping):
|
||||
return None
|
||||
|
||||
unified = False
|
||||
if dify_config.OPS_TRACE_UNIFIED_ENABLED:
|
||||
from core.ops.unified_trace.registry import unified_provider_config_map
|
||||
|
||||
try:
|
||||
unified_provider_config_map[provider]
|
||||
unified = True
|
||||
except KeyError:
|
||||
pass
|
||||
return parent_destination_from_config(provider, trace_config.tracing_config, unified=unified)
|
||||
|
||||
|
||||
class ParentContextCoordinator:
|
||||
"""Publish and resolve cross-task parent contexts for unified providers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: RedisParentContextStore,
|
||||
resolve_parent_destination: ParentDestinationResolver,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._resolve_parent_destination = resolve_parent_destination
|
||||
|
||||
@staticmethod
|
||||
def _key(parent_node_execution_id: str) -> str:
|
||||
return f"{_PARENT_CONTEXT_KEY_PREFIX}{parent_node_execution_id}"
|
||||
|
||||
def publish(self, parent_node_execution_id: str, context: ProviderParentContext) -> None:
|
||||
"""Persist an accepted provider parent so a nested task can restore it."""
|
||||
try:
|
||||
self._store.setex(
|
||||
self._key(parent_node_execution_id),
|
||||
dify_config.OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS,
|
||||
context.model_dump_json(),
|
||||
)
|
||||
except Exception as error:
|
||||
raise TraceParentContextAccessError(
|
||||
f"Could not publish unified parent context for node_execution_id={parent_node_execution_id}"
|
||||
) from error
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
parent: ParentTraceContext,
|
||||
*,
|
||||
expected_provider: str,
|
||||
expected_scope: str,
|
||||
) -> ParentResolution:
|
||||
"""Restore compatible context or explicitly select a linked new root."""
|
||||
destination = self._resolve_parent_destination(parent.parent_workflow_run_id)
|
||||
if (
|
||||
destination is None
|
||||
or not destination.unified
|
||||
or destination.provider != expected_provider
|
||||
or destination.scope != expected_scope
|
||||
):
|
||||
return ParentResolution.linked_root(parent)
|
||||
|
||||
parent_node_execution_id = parent.parent_node_execution_id
|
||||
if not parent_node_execution_id:
|
||||
raise InvalidTraceParentContextError("Nested workflow parent context has no node execution ID")
|
||||
|
||||
return ParentResolution.restored(
|
||||
self._restore(
|
||||
parent_node_execution_id,
|
||||
expected_provider=expected_provider,
|
||||
expected_scope=expected_scope,
|
||||
)
|
||||
)
|
||||
|
||||
def resolve_required(
|
||||
self,
|
||||
parent_context_id: str,
|
||||
*,
|
||||
expected_provider: str,
|
||||
expected_scope: str,
|
||||
) -> ParentResolution:
|
||||
"""Restore a parent context that must exist for an asynchronous child."""
|
||||
return ParentResolution.restored(
|
||||
self._restore(
|
||||
parent_context_id,
|
||||
expected_provider=expected_provider,
|
||||
expected_scope=expected_scope,
|
||||
)
|
||||
)
|
||||
|
||||
def _restore(
|
||||
self,
|
||||
parent_context_id: str,
|
||||
*,
|
||||
expected_provider: str,
|
||||
expected_scope: str,
|
||||
) -> ProviderParentContext:
|
||||
try:
|
||||
raw_context = self._store.get(self._key(parent_context_id))
|
||||
except Exception as error:
|
||||
raise TraceParentContextAccessError(
|
||||
f"Could not read unified parent context for parent_context_id={parent_context_id}"
|
||||
) from error
|
||||
|
||||
if raw_context is None:
|
||||
raise PendingTraceParentContextError(parent_context_id)
|
||||
|
||||
try:
|
||||
context = ProviderParentContext.model_validate_json(raw_context)
|
||||
except (ValidationError, ValueError, TypeError) as error:
|
||||
raise InvalidTraceParentContextError(
|
||||
f"Invalid unified parent context for parent_context_id={parent_context_id}"
|
||||
) from error
|
||||
|
||||
if context.provider != expected_provider or context.scope != expected_scope:
|
||||
raise InvalidTraceParentContextError(
|
||||
"Stored unified parent context does not match the expected provider destination: "
|
||||
f"parent_context_id={parent_context_id}"
|
||||
)
|
||||
return context
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Unified trace runtime and provider adapter contract."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol, override
|
||||
|
||||
from core.ops.base_trace_instance import BaseTraceInstance
|
||||
from core.ops.entities.config_entity import BaseTracingConfig
|
||||
from core.ops.entities.trace_entity import BaseTraceInfo
|
||||
from core.ops.unified_trace.entities import CanonicalTrace
|
||||
from core.ops.unified_trace.parent_context import (
|
||||
ParentContextCoordinator,
|
||||
ParentResolution,
|
||||
ProviderParentContext,
|
||||
)
|
||||
from core.ops.unified_trace.trace_builder import CanonicalTraceBuilder
|
||||
|
||||
ParentContextPublisher = Callable[[str, ProviderParentContext], None]
|
||||
|
||||
|
||||
class UnifiedTraceAdapter(Protocol):
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def scope(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def emit(
|
||||
self,
|
||||
trace: CanonicalTrace,
|
||||
parent: ParentResolution | None,
|
||||
publish_parent_context: ParentContextPublisher,
|
||||
) -> None:
|
||||
"""Emit one Core-resolved, parent-first canonical fragment.
|
||||
|
||||
Return only after the provider-specific synchronous acceptance step succeeds.
|
||||
Raise RetryableTraceDispatchError when acceptance is unconfirmed because of a
|
||||
recoverable provider or transport failure. Raise another exception for a
|
||||
terminal failure. Publish parent context only after the corresponding provider
|
||||
parent span has been accepted.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class UnifiedTraceInstance(BaseTraceInstance):
|
||||
"""Build, coordinate, and emit a trace without a legacy fallback path."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trace_config: BaseTracingConfig,
|
||||
*,
|
||||
builder: CanonicalTraceBuilder,
|
||||
adapter: UnifiedTraceAdapter,
|
||||
coordinator: ParentContextCoordinator,
|
||||
) -> None:
|
||||
super().__init__(trace_config)
|
||||
self._builder = builder
|
||||
self._adapter = adapter
|
||||
self._coordinator = coordinator
|
||||
|
||||
@override
|
||||
def trace(self, trace_info: BaseTraceInfo) -> None:
|
||||
canonical_trace = self._builder.build(trace_info)
|
||||
if canonical_trace is None:
|
||||
return
|
||||
|
||||
parent_resolution = None
|
||||
if canonical_trace.external_parent is not None:
|
||||
parent_resolution = self._coordinator.resolve(
|
||||
canonical_trace.external_parent,
|
||||
expected_provider=self._adapter.provider_name,
|
||||
expected_scope=self._adapter.scope,
|
||||
)
|
||||
elif canonical_trace.required_parent_context_id is not None:
|
||||
parent_resolution = self._coordinator.resolve_required(
|
||||
canonical_trace.required_parent_context_id,
|
||||
expected_provider=self._adapter.provider_name,
|
||||
expected_scope=self._adapter.scope,
|
||||
)
|
||||
|
||||
self._adapter.emit(canonical_trace, parent_resolution, self._coordinator.publish)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Lazy registry for providers implemented by the unified tracing path."""
|
||||
|
||||
import collections
|
||||
from typing import TypedDict, override
|
||||
|
||||
from core.ops.base_trace_instance import BaseTraceInstance
|
||||
from core.ops.entities.config_entity import BaseTracingConfig, TracingProviderEnum
|
||||
|
||||
|
||||
class UnifiedProviderConfigEntry(TypedDict):
|
||||
config_class: type[BaseTracingConfig]
|
||||
trace_instance: type[BaseTraceInstance]
|
||||
|
||||
|
||||
class UnifiedTraceProviderConfigMap(collections.UserDict[str, UnifiedProviderConfigEntry]):
|
||||
"""Resolve unified providers without importing their SDKs until selected."""
|
||||
|
||||
@override
|
||||
def __getitem__(self, key: str) -> UnifiedProviderConfigEntry:
|
||||
match key:
|
||||
case TracingProviderEnum.PHOENIX:
|
||||
from dify_trace_arize_phoenix.config import PhoenixConfig
|
||||
from dify_trace_arize_phoenix.unified_trace import UnifiedPhoenixTrace
|
||||
|
||||
return {"config_class": PhoenixConfig, "trace_instance": UnifiedPhoenixTrace}
|
||||
case TracingProviderEnum.LANGSMITH:
|
||||
from dify_trace_langsmith.config import LangSmithConfig
|
||||
from dify_trace_langsmith.unified_trace import UnifiedLangSmithTrace
|
||||
|
||||
return {"config_class": LangSmithConfig, "trace_instance": UnifiedLangSmithTrace}
|
||||
case _:
|
||||
raise KeyError(f"Unified tracing provider is not registered: {key}")
|
||||
|
||||
|
||||
unified_provider_config_map = UnifiedTraceProviderConfigMap()
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Build provider-neutral traces from core ops trace entities.
|
||||
|
||||
Workflow persistence is accessed through an injected loader. Provider adapters
|
||||
therefore receive a complete parent-first tree and never query Dify models.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.app.workflow.retry_history import RETRY_HISTORY_PROCESS_DATA_KEY
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.ops.entities.trace_entity import (
|
||||
BaseTraceInfo,
|
||||
DatasetRetrievalTraceInfo,
|
||||
GenerateNameTraceInfo,
|
||||
MessageTraceInfo,
|
||||
ModerationTraceInfo,
|
||||
SuggestedQuestionTraceInfo,
|
||||
ToolTraceInfo,
|
||||
WorkflowTraceInfo,
|
||||
)
|
||||
from core.ops.unified_trace.entities import CanonicalSpan, CanonicalSpanKind, CanonicalSpanStatus, CanonicalTrace
|
||||
from core.ops.unified_trace.hierarchy import (
|
||||
WorkflowExecutionLike,
|
||||
build_workflow_hierarchy,
|
||||
execution_id,
|
||||
execution_metadata,
|
||||
)
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from extensions.ext_database import db
|
||||
from models import Account
|
||||
from models.workflow import WorkflowNodeExecutionTriggeredFrom
|
||||
|
||||
WorkflowExecutionLoader = Callable[[WorkflowTraceInfo], Sequence[WorkflowExecutionLike]]
|
||||
ServiceAccountResolver = Callable[[str], Account]
|
||||
|
||||
|
||||
class RepositoryWorkflowExecutionLoader:
|
||||
"""Load one workflow's executions through the tenant-scoped core repository."""
|
||||
|
||||
def __init__(self, get_service_account: ServiceAccountResolver) -> None:
|
||||
self._get_service_account = get_service_account
|
||||
|
||||
def __call__(self, trace_info: WorkflowTraceInfo) -> Sequence[WorkflowExecutionLike]:
|
||||
app_id = trace_info.metadata.get("app_id")
|
||||
if not isinstance(app_id, str) or not app_id:
|
||||
raise ValueError("No app_id found in workflow trace metadata")
|
||||
repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
|
||||
session_factory=sessionmaker(bind=db.engine),
|
||||
tenant_id=trace_info.tenant_id,
|
||||
user=self._get_service_account(app_id),
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
||||
)
|
||||
return repository.get_by_workflow_execution(workflow_execution_id=trace_info.workflow_run_id)
|
||||
|
||||
|
||||
_NODE_KIND: dict[str, CanonicalSpanKind] = {
|
||||
"llm": CanonicalSpanKind.LLM,
|
||||
"knowledge-retrieval": CanonicalSpanKind.RETRIEVER,
|
||||
"tool": CanonicalSpanKind.TOOL,
|
||||
"agent": CanonicalSpanKind.AGENT,
|
||||
}
|
||||
_RETRY_SUMMARY_FIELDS = ("retry_index", "error", "elapsed_time", "created_at", "finished_at")
|
||||
|
||||
|
||||
def _read_attribute(value: object, name: str, default: Any = None) -> Any:
|
||||
"""Read fields shared by persisted trace models and legacy trace objects."""
|
||||
return getattr(value, name, default) # guard-ignore: no-new-getattr -- supports legacy trace objects
|
||||
|
||||
|
||||
def _retry_metadata(process_data: Mapping[str, Any]) -> dict[str, Any]:
|
||||
raw_history = process_data.get(RETRY_HISTORY_PROCESS_DATA_KEY)
|
||||
if not isinstance(raw_history, list):
|
||||
return {}
|
||||
|
||||
attempts: list[dict[str, Any]] = []
|
||||
for raw_attempt in raw_history:
|
||||
if not isinstance(raw_attempt, Mapping):
|
||||
continue
|
||||
retry_index = raw_attempt.get("retry_index")
|
||||
if isinstance(retry_index, bool) or not isinstance(retry_index, int) or retry_index <= 0:
|
||||
continue
|
||||
attempts.append({field: raw_attempt.get(field) for field in _RETRY_SUMMARY_FIELDS})
|
||||
|
||||
return {"retry_count": len(attempts), "retry_attempts": attempts} if attempts else {}
|
||||
|
||||
|
||||
def resolve_session_id(trace_info: WorkflowTraceInfo | MessageTraceInfo) -> str:
|
||||
"""Resolve an explicit trace session before stable Dify fallbacks."""
|
||||
custom_session_id = trace_info.metadata.get("trace_session_id")
|
||||
if isinstance(custom_session_id, str) and custom_session_id:
|
||||
return custom_session_id
|
||||
|
||||
if isinstance(trace_info, WorkflowTraceInfo):
|
||||
if trace_info.conversation_id:
|
||||
return trace_info.conversation_id
|
||||
parent_workflow_run_id, _ = trace_info.resolved_parent_context
|
||||
return parent_workflow_run_id or trace_info.workflow_run_id
|
||||
|
||||
if trace_info.message_data is None:
|
||||
return ""
|
||||
conversation_id = _read_attribute(trace_info.message_data, "conversation_id")
|
||||
return conversation_id if isinstance(conversation_id, str) else ""
|
||||
|
||||
|
||||
def _status(error: str | None, status: Any = None) -> CanonicalSpanStatus:
|
||||
status_value = _read_attribute(status, "value", status)
|
||||
return CanonicalSpanStatus.ERROR if error or status_value in {"failed", "exception"} else CanonicalSpanStatus.OK
|
||||
|
||||
|
||||
def _external_parent(trace_info: WorkflowTraceInfo) -> ParentTraceContext | None:
|
||||
value = trace_info.metadata.get("parent_trace_context")
|
||||
if isinstance(value, ParentTraceContext):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
try:
|
||||
return ParentTraceContext.model_validate(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _started_at(value: datetime | None) -> datetime:
|
||||
return value or datetime.now()
|
||||
|
||||
|
||||
def _single_session_id(trace_info: BaseTraceInfo) -> str:
|
||||
value = trace_info.metadata.get("trace_session_id")
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
class CanonicalTraceBuilder:
|
||||
"""Convert supported ops trace entities into canonical parent-first trees."""
|
||||
|
||||
def __init__(self, load_workflow_executions: WorkflowExecutionLoader) -> None:
|
||||
self._load_workflow_executions = load_workflow_executions
|
||||
|
||||
def build(self, trace_info: BaseTraceInfo) -> CanonicalTrace | None:
|
||||
match trace_info:
|
||||
case WorkflowTraceInfo():
|
||||
return self._build_workflow(trace_info)
|
||||
case MessageTraceInfo():
|
||||
return self._build_message(trace_info)
|
||||
case ModerationTraceInfo():
|
||||
return self._build_moderation(trace_info)
|
||||
case SuggestedQuestionTraceInfo():
|
||||
return self._build_suggested_question(trace_info)
|
||||
case DatasetRetrievalTraceInfo():
|
||||
return self._build_dataset_retrieval(trace_info)
|
||||
case ToolTraceInfo():
|
||||
return self._build_tool(trace_info)
|
||||
case GenerateNameTraceInfo():
|
||||
return self._build_generate_name(trace_info)
|
||||
case _:
|
||||
return None
|
||||
|
||||
def _build_workflow(self, trace_info: WorkflowTraceInfo) -> CanonicalTrace:
|
||||
executions = self._load_workflow_executions(trace_info)
|
||||
hierarchy = build_workflow_hierarchy(executions)
|
||||
workflow_data = trace_info.workflow_data
|
||||
workflow_start = _started_at(_read_attribute(workflow_data, "created_at") or trace_info.start_time)
|
||||
workflow_end = _read_attribute(workflow_data, "finished_at") or trace_info.end_time
|
||||
message_span_id = trace_info.message_id
|
||||
workflow_span_id = trace_info.workflow_run_id
|
||||
spans: dict[str, CanonicalSpan] = {}
|
||||
|
||||
if message_span_id:
|
||||
spans[message_span_id] = CanonicalSpan(
|
||||
id=message_span_id,
|
||||
parent_id=None,
|
||||
name=f"chatflow_{trace_info.workflow_run_id}",
|
||||
kind=CanonicalSpanKind.CHAIN,
|
||||
start_time=_started_at(trace_info.start_time or workflow_start),
|
||||
end_time=trace_info.end_time or workflow_end,
|
||||
inputs=trace_info.query or dict(trace_info.workflow_run_inputs),
|
||||
outputs=dict(trace_info.workflow_run_outputs),
|
||||
status=_status(trace_info.error),
|
||||
error=trace_info.error or None,
|
||||
metadata={**trace_info.metadata, "trace_entity_type": "message"},
|
||||
publishes_parent_context=True,
|
||||
)
|
||||
root_span_id = message_span_id
|
||||
workflow_parent_id: str | None = message_span_id
|
||||
else:
|
||||
root_span_id = workflow_span_id
|
||||
workflow_parent_id = None
|
||||
|
||||
spans[workflow_span_id] = CanonicalSpan(
|
||||
id=workflow_span_id,
|
||||
parent_id=workflow_parent_id,
|
||||
name=f"workflow_{workflow_span_id}",
|
||||
kind=CanonicalSpanKind.CHAIN,
|
||||
start_time=workflow_start,
|
||||
end_time=workflow_end,
|
||||
inputs=dict(trace_info.workflow_run_inputs),
|
||||
outputs=dict(trace_info.workflow_run_outputs),
|
||||
status=_status(trace_info.error, trace_info.workflow_run_status),
|
||||
error=trace_info.error or None,
|
||||
metadata={
|
||||
**trace_info.metadata,
|
||||
"workflow_id": trace_info.workflow_id,
|
||||
"workflow_run_id": workflow_span_id,
|
||||
"workflow_app_log_id": trace_info.workflow_app_log_id,
|
||||
"total_tokens": trace_info.total_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
execution_by_id = {execution_id(item): item for item in executions}
|
||||
for wrapper in hierarchy.wrappers:
|
||||
spans[wrapper.id] = CanonicalSpan(
|
||||
id=wrapper.id,
|
||||
parent_id=wrapper.parent_execution_id,
|
||||
name=f"{wrapper.key.kind}[{wrapper.key.index}]",
|
||||
kind=CanonicalSpanKind.CHAIN,
|
||||
start_time=wrapper.start_time,
|
||||
end_time=wrapper.end_time,
|
||||
status=CanonicalSpanStatus.ERROR if wrapper.has_error else CanonicalSpanStatus.OK,
|
||||
error="wrapper child failed" if wrapper.has_error else None,
|
||||
metadata={
|
||||
"wrapper_type": wrapper.key.kind,
|
||||
"wrapper_index": wrapper.key.index,
|
||||
"container_execution_id": wrapper.parent_execution_id,
|
||||
},
|
||||
synthetic=True,
|
||||
)
|
||||
|
||||
for item_execution_id, item in execution_by_id.items():
|
||||
process_data = _read_attribute(item, "process_data") or {}
|
||||
outputs = _read_attribute(item, "outputs") or {}
|
||||
node_type = str(_read_attribute(item, "node_type", ""))
|
||||
error = _read_attribute(item, "error")
|
||||
started_at = _started_at(_read_attribute(item, "created_at"))
|
||||
elapsed_time = _read_attribute(item, "elapsed_time") or 0
|
||||
metadata = dict(execution_metadata(item))
|
||||
metadata.update(
|
||||
{
|
||||
"node_id": _read_attribute(item, "node_id", ""),
|
||||
"node_execution_id": item_execution_id,
|
||||
"node_type": node_type,
|
||||
"status": _read_attribute(item, "status", ""),
|
||||
"model_provider": process_data.get("model_provider"),
|
||||
"model_name": process_data.get("model_name"),
|
||||
}
|
||||
)
|
||||
usage = process_data.get("usage") or (outputs.get("usage") if isinstance(outputs, Mapping) else None) or {}
|
||||
if isinstance(usage, Mapping):
|
||||
metadata.update(
|
||||
{
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
}
|
||||
)
|
||||
metadata.update(_retry_metadata(process_data))
|
||||
title = _read_attribute(item, "title")
|
||||
name = f"{node_type}_{title}" if isinstance(title, str) and title else node_type
|
||||
spans[item_execution_id] = CanonicalSpan(
|
||||
id=item_execution_id,
|
||||
parent_id=hierarchy.parent_by_execution_id.get(item_execution_id, workflow_span_id),
|
||||
name=name,
|
||||
kind=_NODE_KIND.get(node_type, CanonicalSpanKind.CHAIN),
|
||||
start_time=started_at,
|
||||
end_time=started_at + timedelta(seconds=elapsed_time),
|
||||
inputs=process_data.get("prompts", []) if node_type == "llm" else _read_attribute(item, "inputs") or {},
|
||||
outputs=outputs,
|
||||
status=_status(error, _read_attribute(item, "status")),
|
||||
error=error,
|
||||
metadata=metadata,
|
||||
can_parent_workflow=node_type == "tool",
|
||||
)
|
||||
|
||||
ordered: list[CanonicalSpan] = []
|
||||
emitted: set[str] = set()
|
||||
|
||||
def emit(span_id: str) -> None:
|
||||
if span_id in emitted:
|
||||
return
|
||||
span = spans[span_id]
|
||||
if span.parent_id in spans:
|
||||
emit(span.parent_id)
|
||||
emitted.add(span_id)
|
||||
ordered.append(span)
|
||||
|
||||
for span_id in sorted(spans):
|
||||
emit(span_id)
|
||||
|
||||
return CanonicalTrace(
|
||||
trace_id=trace_info.resolved_trace_id or root_span_id,
|
||||
session_id=resolve_session_id(trace_info),
|
||||
root_span_id=root_span_id,
|
||||
spans=tuple(ordered),
|
||||
external_parent=_external_parent(trace_info),
|
||||
)
|
||||
|
||||
def _single_trace(
|
||||
self,
|
||||
trace_info: BaseTraceInfo,
|
||||
*,
|
||||
name: str,
|
||||
kind: CanonicalSpanKind,
|
||||
inputs: Any,
|
||||
outputs: Any,
|
||||
error: str | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
parent_context_id: str | None = None,
|
||||
span_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> CanonicalTrace:
|
||||
operation_id = span_id or trace_info.operation_id or str(uuid4())
|
||||
trace_id = trace_info.resolved_trace_id or parent_context_id or operation_id
|
||||
span = CanonicalSpan(
|
||||
id=operation_id,
|
||||
parent_id=None,
|
||||
name=name,
|
||||
kind=kind,
|
||||
start_time=_started_at(start_time or trace_info.start_time),
|
||||
end_time=end_time or trace_info.end_time,
|
||||
inputs=inputs,
|
||||
outputs=outputs,
|
||||
status=_status(error),
|
||||
error=error,
|
||||
metadata=dict(trace_info.metadata),
|
||||
)
|
||||
return CanonicalTrace(
|
||||
trace_id=trace_id,
|
||||
session_id=session_id if session_id is not None else _single_session_id(trace_info),
|
||||
root_span_id=operation_id,
|
||||
spans=(span,),
|
||||
required_parent_context_id=parent_context_id,
|
||||
)
|
||||
|
||||
def _build_message(self, trace_info: MessageTraceInfo) -> CanonicalTrace | None:
|
||||
message = trace_info.message_data
|
||||
if message is None:
|
||||
return None
|
||||
message_id = trace_info.message_id or str(_read_attribute(message, "id", "")) or str(uuid4())
|
||||
started_at = _started_at(trace_info.start_time or _read_attribute(message, "created_at"))
|
||||
ended_at = trace_info.end_time or _read_attribute(message, "updated_at")
|
||||
answer = _read_attribute(message, "answer", trace_info.outputs)
|
||||
message_error = trace_info.error or _read_attribute(message, "error")
|
||||
metadata = {
|
||||
**trace_info.metadata,
|
||||
"trace_entity_type": "message",
|
||||
"model_provider": _read_attribute(message, "model_provider"),
|
||||
"model_name": _read_attribute(message, "model_id"),
|
||||
"prompt_tokens": trace_info.message_tokens,
|
||||
"completion_tokens": trace_info.answer_tokens,
|
||||
"total_tokens": trace_info.total_tokens,
|
||||
}
|
||||
spans: list[CanonicalSpan] = [
|
||||
CanonicalSpan(
|
||||
id=message_id,
|
||||
parent_id=None,
|
||||
name="message",
|
||||
kind=CanonicalSpanKind.CHAIN,
|
||||
start_time=started_at,
|
||||
end_time=ended_at,
|
||||
inputs=trace_info.inputs,
|
||||
outputs=answer,
|
||||
status=_status(message_error),
|
||||
error=message_error,
|
||||
metadata=metadata,
|
||||
publishes_parent_context=True,
|
||||
),
|
||||
CanonicalSpan(
|
||||
id=f"{message_id}:llm",
|
||||
parent_id=message_id,
|
||||
name="llm",
|
||||
kind=CanonicalSpanKind.LLM,
|
||||
start_time=started_at,
|
||||
end_time=ended_at,
|
||||
inputs=trace_info.inputs,
|
||||
outputs=trace_info.outputs if trace_info.outputs is not None else answer,
|
||||
status=_status(message_error),
|
||||
error=message_error,
|
||||
metadata=metadata,
|
||||
synthetic=True,
|
||||
),
|
||||
]
|
||||
return CanonicalTrace(
|
||||
trace_id=trace_info.resolved_trace_id or message_id,
|
||||
session_id=resolve_session_id(trace_info),
|
||||
root_span_id=message_id,
|
||||
spans=tuple(spans),
|
||||
)
|
||||
|
||||
def _build_moderation(self, trace_info: ModerationTraceInfo) -> CanonicalTrace | None:
|
||||
if trace_info.message_data is None:
|
||||
return None
|
||||
return self._single_trace(
|
||||
trace_info,
|
||||
name="moderation",
|
||||
kind=CanonicalSpanKind.TOOL,
|
||||
inputs=trace_info.inputs,
|
||||
outputs={"action": trace_info.action, "flagged": trace_info.flagged},
|
||||
parent_context_id=trace_info.message_id,
|
||||
)
|
||||
|
||||
def _build_suggested_question(self, trace_info: SuggestedQuestionTraceInfo) -> CanonicalTrace | None:
|
||||
if trace_info.message_data is None:
|
||||
return None
|
||||
return self._single_trace(
|
||||
trace_info,
|
||||
name="suggested_question",
|
||||
kind=CanonicalSpanKind.TOOL,
|
||||
inputs=trace_info.inputs,
|
||||
outputs=trace_info.suggested_question,
|
||||
error=trace_info.error,
|
||||
parent_context_id=trace_info.message_id,
|
||||
)
|
||||
|
||||
def _build_dataset_retrieval(self, trace_info: DatasetRetrievalTraceInfo) -> CanonicalTrace | None:
|
||||
if trace_info.message_data is None:
|
||||
return None
|
||||
return self._single_trace(
|
||||
trace_info,
|
||||
name="dataset_retrieval",
|
||||
kind=CanonicalSpanKind.RETRIEVER,
|
||||
inputs=trace_info.inputs,
|
||||
outputs={"documents": trace_info.documents},
|
||||
error=trace_info.error,
|
||||
parent_context_id=trace_info.message_id,
|
||||
)
|
||||
|
||||
def _build_tool(self, trace_info: ToolTraceInfo) -> CanonicalTrace:
|
||||
return self._single_trace(
|
||||
trace_info,
|
||||
name=trace_info.tool_name,
|
||||
kind=CanonicalSpanKind.TOOL,
|
||||
inputs=trace_info.tool_inputs,
|
||||
outputs=trace_info.tool_outputs,
|
||||
error=trace_info.error,
|
||||
parent_context_id=trace_info.message_id,
|
||||
)
|
||||
|
||||
def _build_generate_name(self, trace_info: GenerateNameTraceInfo) -> CanonicalTrace:
|
||||
return self._single_trace(
|
||||
trace_info,
|
||||
name="generate_name",
|
||||
kind=CanonicalSpanKind.TOOL,
|
||||
inputs=trace_info.inputs,
|
||||
outputs=trace_info.outputs,
|
||||
parent_context_id=trace_info.message_id,
|
||||
session_id=_single_session_id(trace_info) or trace_info.conversation_id or "",
|
||||
)
|
||||
Reference in new issue
Block a user